Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions schema.neo4j.json
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,29 @@
"properties": {
"var": "string"
}
},
{
"type": "EXTENDS",
"from": [
"Class",
"Interface"
],
"to": [
"Class",
"Interface"
],
"properties": {}
},
{
"type": "IMPLEMENTS",
"from": [
"Class"
],
"to": [
"Interface",
"Class"
],
"properties": {}
}
],
"constraints": [
Expand Down
4 changes: 4 additions & 0 deletions src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,10 @@ function projectType(b: RowBuilder, t: V2Type, parent: NodeRef, fileKey: string)
const label = KIND_LABEL[t.kind] ?? "Class";
const node = b.node([CAN, label], "id", t.id, typeProps(t, fileKey));
b.edge("DECLARES", parent, node);
// Inheritance overlay — resolved-only (emit.ts already dropped unresolved/external supertypes);
// the deferred gate is defense-in-depth against a resolved id that never materialized as a node.
for (const eid of t.extends_ids ?? []) b.edgeToSymbol("EXTENDS", node, eid);
for (const iid of t.implements_ids ?? []) b.edgeToSymbol("IMPLEMENTS", node, iid);
if (t.kind === "namespace") {
projectScope(b, t, node, fileKey); // a namespace nests types/functions/fields
return;
Expand Down
Binary file modified src/build/neo4j/rows.ts
Binary file not shown.
6 changes: 6 additions & 0 deletions src/build/neo4j/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,12 @@ export const REL_TYPES: RelType[] = [
{ type: "SUMMARY", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
{ type: "PARAM_IN", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
{ type: "PARAM_OUT", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
// Inheritance, projected from the `extends_ids`/`implements_ids` node props (schema/v2/emit.ts) —
// resolved-only: an unresolved (external/library) supertype never reaches here. A `to` of `Class`
// covers TS's `implements SomeClass` (structural, not just interfaces); an interface may itself
// `extends` a class's instance type, hence `EXTENDS` also allows an `Interface` source.
{ type: "EXTENDS", from: ["Class", "Interface"], to: ["Class", "Interface"], properties: {} },
{ type: "IMPLEMENTS", from: ["Class"], to: ["Interface", "Class"], properties: {} },
];

// ----------------------------------------------------------------------------------------------
Expand Down
36 changes: 33 additions & 3 deletions src/schema/v2/emit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,11 +111,19 @@ function memberKey(sig: string, accessorKind?: string | null): string {
return seg;
}

/** A type's heritage signatures, resolved to `can://` ids once the whole tree walk registers them. */
interface PendingHeritage {
node: V2Type;
extendsSigs: string[]; // class: the extended base class; interface: extended interface(s)
implementsSigs: string[]; // class only: implemented interfaces
}

/** State shared across the whole tree walk (edge-rewriting + gating). */
interface SharedState {
idBySig: Map<string, string>;
collisions: string[];
pendingCallees: Array<{ node: V2BodyNode; calleeSig: string | null }>; // backfilled at L2
pendingHeritage: PendingHeritage[]; // resolved sig→id once the whole tree walk completes
callableBySig: Map<string, V2Callable>; // locates each callable's node for the L3/L4 dataflow pass
level: number;
}
Expand Down Expand Up @@ -201,7 +209,14 @@ function toClass(c: TSClass, ctx: Ctx): V2Type {
const fields: Record<string, V2Field> = {};
for (const [name, a] of Object.entries(c.attributes ?? {}))
fields[name] = fieldNode(id, name, (a as TSClassAttribute).span, a as unknown as Record<string, unknown>);
return { ...carry(c as unknown as Record<string, unknown>), id, kind: "class", signature: c.signature, span: c.span, callables, fields };
const node: V2Type = { ...carry(c as unknown as Record<string, unknown>), id, kind: "class", signature: c.signature, span: c.span, callables, fields };
// `base_classes` is the union of extends + implements (schema.ts:231); subtract implements_types
// to recover just the extended base class (0 or 1 — TS classes extend at most one class).
if (c.base_classes.length) {
const extendsSigs = c.base_classes.filter((s) => !c.implements_types.includes(s));
ctx.pendingHeritage.push({ node, extendsSigs, implementsSigs: c.implements_types });
}
return node;
}

function toInterface(i: TSInterface, ctx: Ctx): V2Type {
Expand All @@ -212,7 +227,11 @@ function toInterface(i: TSInterface, ctx: Ctx): V2Type {
const fields: Record<string, V2Field> = {};
for (const [name, p] of Object.entries(i.properties ?? {}))
fields[name] = fieldNode(id, name, (p as TSClassAttribute).span, p as unknown as Record<string, unknown>);
return { ...carry(i as unknown as Record<string, unknown>), id, kind: "interface", signature: i.signature, span: i.span, callables, fields };
const node: V2Type = { ...carry(i as unknown as Record<string, unknown>), id, kind: "interface", signature: i.signature, span: i.span, callables, fields };
// Interface heritage is extends-only (schema.ts:255) — an interface can extend other interfaces
// (or, rarely, a class's instance type), but never "implements".
if (i.base_classes.length) ctx.pendingHeritage.push({ node, extendsSigs: i.base_classes, implementsSigs: [] });
return node;
}

function toEnum(e: TSEnum, ctx: Ctx): V2Type {
Expand Down Expand Up @@ -332,8 +351,9 @@ export function toV2Detailed(app: TSApplication, opts: AnalysisOptions): ToV2Res
const idBySig = new Map<string, string>();
const collisions: string[] = [];
const pendingCallees: Array<{ node: V2BodyNode; calleeSig: string | null }> = [];
const pendingHeritage: PendingHeritage[] = [];
const callableBySig = new Map<string, V2Callable>();
const shared: SharedState = { idBySig, collisions, pendingCallees, callableBySig, level };
const shared: SharedState = { idBySig, collisions, pendingCallees, pendingHeritage, callableBySig, level };

// L1 — the containment tree (registers every real callable/type id in idBySig).
const symbol_table: Record<string, V2Module> = {};
Expand All @@ -342,6 +362,16 @@ export function toV2Detailed(app: TSApplication, opts: AnalysisOptions): ToV2Res
}
const root: V2Root = { id: appId, kind: "application", symbol_table, call_graph: [], param_in: [], param_out: [] };

// Resolve heritage sig → can:// id now that every first-party type is registered in idBySig
// (independent of level: types are homed during the unconditional L1 walk above). Unresolved
// (external/library) supertypes are dropped, never nulled — the "resolved-only" rule.
for (const { node, extendsSigs, implementsSigs } of pendingHeritage) {
const extendsIds = extendsSigs.map((s) => idBySig.get(s)).filter((x): x is string => x !== undefined);
const implementsIds = implementsSigs.map((s) => idBySig.get(s)).filter((x): x is string => x !== undefined);
if (extendsIds.length) node.extends_ids = extendsIds;
if (implementsIds.length) node.implements_ids = implementsIds;
}

// L2 — home the off-tree edge endpoints, backfill `callee`, rewrite the call graph.
const dangling: string[] = [];
if (level >= 2) {
Expand Down
5 changes: 5 additions & 0 deletions src/schema/v2/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,11 @@ export interface V2Type extends V2Node {
fields?: Record<string, V2Field>; // class attributes / interface properties / enum members
types?: Record<string, V2Type>; // namespace: nested types
functions?: Record<string, V2Callable>; // namespace: nested functions
// Heritage: `base_classes`/`implements_types` (carried from v1) stay signature strings — the
// resolved-id projection lives here, additively, for the Neo4j EXTENDS/IMPLEMENTS overlay.
// External/library supertypes that never resolve to a first-party id are dropped, not nulled.
extends_ids?: string[]; // resolved `can://` id(s) of the extended class/interface(s)
implements_ids?: string[]; // resolved `can://` id(s) of implemented interfaces (classes only)
}

export interface V2Callable extends V2Node {
Expand Down
28 changes: 28 additions & 0 deletions test/fixtures/dataflow-app/src/hierarchy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/** A minimal first-party class hierarchy — exercises the Neo4j EXTENDS/IMPLEMENTS projection. */

export interface Shape {
area(): number;
}

export interface Labeled {
readonly label: string;
}

export class Rectangle implements Shape {
constructor(
protected readonly width: number,
protected readonly height: number,
) {}

area(): number {
return this.width * this.height;
}
}

export class Square extends Rectangle implements Labeled {
readonly label = "square";

constructor(side: number) {
super(side, side);
}
}
41 changes: 41 additions & 0 deletions test/neo4j-schema.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -91,3 +91,44 @@ describe("neo4j schema conformance", () => {
expect(onDisk).toBe(fresh);
});
});

// ---- Class inheritance: EXTENDS/IMPLEMENTS (issue #33) ------------------------------------------
// dataflow-app's src/hierarchy.ts is a minimal, first-party heritage fixture: `Rectangle implements
// Shape`, `Square extends Rectangle implements Labeled`.

describe("neo4j inheritance edges (issue #33)", () => {
test("EXTENDS and IMPLEMENTS are declared in the schema catalog", () => {
expect(relByType.has("EXTENDS")).toBe(true);
expect(relByType.has("IMPLEMENTS")).toBe(true);
});

function nodeBySignature(signature: string) {
return rows.nodes.find((n) => n.props.signature === signature);
}

test("hierarchy.ts's first-party heritage projects the expected, non-dangling EXTENDS/IMPLEMENTS edges", () => {
const square = nodeBySignature("src/hierarchy.Square");
const rectangle = nodeBySignature("src/hierarchy.Rectangle");
const shape = nodeBySignature("src/hierarchy.Shape");
const labeled = nodeBySignature("src/hierarchy.Labeled");
expect(square, "Square node").toBeDefined();
expect(rectangle, "Rectangle node").toBeDefined();
expect(shape, "Shape node").toBeDefined();
expect(labeled, "Labeled node").toBeDefined();

const ext = rows.edges.filter((e) => e.type === "EXTENDS");
const impl = rows.edges.filter((e) => e.type === "IMPLEMENTS");
expect(ext.length).toBeGreaterThan(0);
expect(impl.length).toBeGreaterThan(0);

expect(ext.some((e) => e.from.value === square!.value && e.to.value === rectangle!.value)).toBe(true);
expect(impl.some((e) => e.from.value === rectangle!.value && e.to.value === shape!.value)).toBe(true);
expect(impl.some((e) => e.from.value === square!.value && e.to.value === labeled!.value)).toBe(true);

const nodeValues = new Set(rows.nodes.map((n) => n.value));
for (const e of [...ext, ...impl]) {
expect(nodeValues.has(e.from.value), `dangling EXTENDS/IMPLEMENTS source ${e.from.value}`).toBe(true);
expect(nodeValues.has(e.to.value), `dangling EXTENDS/IMPLEMENTS target ${e.to.value}`).toBe(true);
}
});
});
122 changes: 121 additions & 1 deletion test/schema-v2.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,58 @@ describe("schema v2 — L1 tree shape", () => {
});
});

// ---- Inheritance: heritage signatures resolve to can:// ids (issue #33) ------------------------
// sample-app's models.ts is first-party heritage: Entity implements Identifiable; User extends
// Entity implements Named; Robot implements Named (a second, unrelated implementer of Named).
// This resolution is unconditional (independent of `-a` level) — it only needs idBySig, which the
// unconditional L1 tree walk already populates — so it's exercised here even though `st` is L1.
describe("schema v2 — inheritance resolves heritage signatures to can:// ids (issue #33)", () => {
test("a class's extends/implements signatures resolve to their declaring node's id", () => {
const types = st["src/models.ts"].types;
const entity = types.Entity;
const user = types.User;
const robot = types.Robot;
const identifiable = types.Identifiable;
const named = types.Named;

expect(entity.extends_ids).toBeUndefined(); // no base class, only `implements`
expect(entity.implements_ids).toEqual([identifiable.id]);

expect(user.extends_ids).toEqual([entity.id]);
expect(user.implements_ids).toEqual([named.id]);

expect(robot.extends_ids).toBeUndefined();
expect(robot.implements_ids).toEqual([named.id]);
});

test("base_classes/implements_types (signature strings) are unchanged — additive, not replaced", () => {
const entity = st["src/models.ts"].types.Entity as unknown as { base_classes: string[]; implements_types: string[] };
const user = st["src/models.ts"].types.User as unknown as { base_classes: string[]; implements_types: string[] };
const identifiableSig = st["src/models.ts"].types.Identifiable.signature;
const entitySig = st["src/models.ts"].types.Entity.signature;
const namedSig = st["src/models.ts"].types.Named.signature;

expect(entity.base_classes).toEqual([identifiableSig]);
expect(entity.implements_types).toEqual([identifiableSig]);
expect(user.base_classes).toEqual([entitySig, namedSig]);
expect(user.implements_types).toEqual([namedSig]);
});

test("an unresolved (external/library) supertype is dropped, not nulled", () => {
// Every type in sample-app either has no heritage or fully first-party heritage, so there is no
// dropped entry to observe directly here — instead assert the shape invariant the resolver
// relies on: extends_ids/implements_ids, when present, are always non-empty arrays of can://
// ids (never contain undefined/null placeholders for an unresolved signature).
for (const t of Object.values(st["src/models.ts"].types)) {
for (const ids of [t.extends_ids, t.implements_ids]) {
if (ids === undefined) continue;
expect(ids.length).toBeGreaterThan(0);
for (const id of ids) expect(id.startsWith("can://")).toBe(true);
}
}
});
});

describe("schema v2 — L1 superset", () => {
test("every v1 callable/type signature has a v2 id", () => {
const v1sigs = new Set<string>();
Expand Down Expand Up @@ -511,6 +563,26 @@ function symbolIds(app: V2Application): Set<string> {
return out;
}

/** Every type node — classes/interfaces/enums/aliases/namespaces — recursing through nested scopes. */
function allTypes(app: V2Application): V2Type[] {
const out: V2Type[] = [];
const walkCallable = (c: V2Callable): void => {
for (const cc of Object.values(c.callables ?? {})) walkCallable(cc);
for (const t of Object.values(c.types ?? {})) walkType(t);
};
const walkType = (t: V2Type): void => {
out.push(t);
for (const c of Object.values(t.callables ?? {})) walkCallable(c);
for (const nested of Object.values(t.types ?? {})) walkType(nested);
for (const fn of Object.values(t.functions ?? {})) walkCallable(fn);
};
for (const m of Object.values(app.application.symbol_table)) {
for (const t of Object.values(m.types)) walkType(t);
for (const c of Object.values(m.functions)) walkCallable(c);
}
return out;
}

/** Every body-node key, namespaced by its owning callable id so bare local keys never collide. */
function bodyKeys(app: V2Application): Set<string> {
const out = new Set<string>();
Expand Down Expand Up @@ -602,6 +674,15 @@ function canNodeIds(app: V2Application): Set<string> {
return ids;
}

/** Every `call` body node whose `callee` resolved to an id — the JSON-side source of RESOLVES_TO. */
function resolvesToCount(app: V2Application): number {
let n = 0;
for (const c of allCallables(app.application)) {
for (const bn of Object.values(c.body)) if (typeof bn.callee === "string") n++;
}
return n;
}

describe("neo4j ↔ json count parity — full depth (issue #27)", () => {
test("node count: 1 :Application row + every :CanNode id", () => {
expect(monoRows.nodes.length).toBe(1 + canNodeIds(monoApp4).size);
Expand All @@ -622,11 +703,50 @@ describe("neo4j ↔ json count parity — full depth (issue #27)", () => {
// HAS_MODULE/DECLARES/HAS_METHOD/HAS_FIELD/HAS_BODY_NODE have no JSON edge-list counterpart —
// they ARE the containment tree. Every node except :Application and the off-tree External/
// AnonymousCallable homes gets exactly one incoming containment edge from its tree parent, so
// their total must equal every other CanNode id.
// their total must equal every other CanNode id. (RESOLVES_TO and EXTENDS/IMPLEMENTS ALSO have
// no top-level JSON edge-list — they're sourced from a body node's `callee` and a type node's
// `extends_ids`/`implements_ids` props respectively — but they are not containment either, so
// they're deliberately excluded from this invariant too; see the dedicated tests below and the
// exhaustive edge-accounting test that folds every relationship family back into one total.)
const containment = ["HAS_MODULE", "DECLARES", "HAS_METHOD", "HAS_FIELD", "HAS_BODY_NODE"];
const containmentEdges = containment.reduce((n, t) => n + relCount(monoRows, t), 0);
const externalCount = Object.keys(monoApp4.application.external_symbols ?? {}).length;
const synthCount = Object.keys(monoApp4.application.synthesized_callables ?? {}).length;
expect(containmentEdges).toBe(canNodeIds(monoApp4).size - externalCount - synthCount);
});

test("EXTENDS/IMPLEMENTS have no JSON edge-list (extends_ids/implements_ids node props are the source of truth); counts still match 1:1", () => {
const types = allTypes(monoApp4);
const extendsCount = types.reduce((n, t) => n + (t.extends_ids?.length ?? 0), 0);
const implementsCount = types.reduce((n, t) => n + (t.implements_ids?.length ?? 0), 0);
// sanity: dataflow-app's first-party hierarchy (src/hierarchy.ts) exercises both relationship
// kinds — guards against a vacuous 0 === 0 pass if the fixture ever loses its heritage.
expect(extendsCount).toBeGreaterThan(0);
expect(implementsCount).toBeGreaterThan(0);
expect(relCount(monoRows, "EXTENDS")).toBe(extendsCount);
expect(relCount(monoRows, "IMPLEMENTS")).toBe(implementsCount);
});

test("every projected edge is accounted for: typed overlay + containment + RESOLVES_TO + EXTENDS/IMPLEMENTS sums to the total", () => {
// The exhaustive form of the parity gate: unlike the per-family tests above (which only prove
// each family individually matches its JSON source), this proves nothing is left over — if
// project.ts ever grows a new relationship family without this test learning about it, the two
// sides diverge and the gate fails, instead of silently passing on an incomplete accounting.
const typedOverlay =
relCount(monoRows, "CALLS") +
relCount(monoRows, "PARAM_IN") +
relCount(monoRows, "PARAM_OUT") +
relCount(monoRows, "CFG_NEXT") +
relCount(monoRows, "CDG") +
relCount(monoRows, "DDG") +
relCount(monoRows, "SUMMARY");
const containment = ["HAS_MODULE", "DECLARES", "HAS_METHOD", "HAS_FIELD", "HAS_BODY_NODE"].reduce(
(n, t) => n + relCount(monoRows, t),
0,
);
const resolvesTo = relCount(monoRows, "RESOLVES_TO");
const heritage = relCount(monoRows, "EXTENDS") + relCount(monoRows, "IMPLEMENTS");
expect(resolvesTo).toBe(resolvesToCount(monoApp4));
expect(typedOverlay + containment + resolvesTo + heritage).toBe(monoRows.edges.length);
});
});