Skip to content

Commit bdd1aed

Browse files
committed
feat: project class inheritance as EXTENDS/IMPLEMENTS neo4j relationships
Resolves each type's heritage signatures (base_classes/implements_types) through idBySig to can:// ids in the v2 emitter, dropping unresolved external/library supertypes rather than nulling them. Exposes the result as extends_ids/implements_ids on the V2 type node (JSON props unchanged) and projects them as EXTENDS/IMPLEMENTS relationships in Neo4j via the previously-dead RowBuilder.edgeToSymbol deferred gate, which was also re-pointed at the correct CanNode/id addressing (it had been hardcoded to a Symbol/signature pair no node in the v2 schema actually carries). Adds a first-party class hierarchy fixture to dataflow-app and extends both the schema conformance test and the issue #27 Neo4j<->JSON parity gate (the latter now also has an exhaustive total-edge accounting test).
1 parent f0d78ce commit bdd1aed

9 files changed

Lines changed: 261 additions & 4 deletions

File tree

schema.neo4j.json

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -372,6 +372,29 @@
372372
"properties": {
373373
"var": "string"
374374
}
375+
},
376+
{
377+
"type": "EXTENDS",
378+
"from": [
379+
"Class",
380+
"Interface"
381+
],
382+
"to": [
383+
"Class",
384+
"Interface"
385+
],
386+
"properties": {}
387+
},
388+
{
389+
"type": "IMPLEMENTS",
390+
"from": [
391+
"Class"
392+
],
393+
"to": [
394+
"Interface",
395+
"Class"
396+
],
397+
"properties": {}
375398
}
376399
],
377400
"constraints": [

src/build/neo4j/project.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,10 @@ function projectType(b: RowBuilder, t: V2Type, parent: NodeRef, fileKey: string)
9696
const label = KIND_LABEL[t.kind] ?? "Class";
9797
const node = b.node([CAN, label], "id", t.id, typeProps(t, fileKey));
9898
b.edge("DECLARES", parent, node);
99+
// Inheritance overlay — resolved-only (emit.ts already dropped unresolved/external supertypes);
100+
// the deferred gate is defense-in-depth against a resolved id that never materialized as a node.
101+
for (const eid of t.extends_ids ?? []) b.edgeToSymbol("EXTENDS", node, eid);
102+
for (const iid of t.implements_ids ?? []) b.edgeToSymbol("IMPLEMENTS", node, iid);
99103
if (t.kind === "namespace") {
100104
projectScope(b, t, node, fileKey); // a namespace nests types/functions/fields
101105
return;

src/build/neo4j/rows.ts

209 Bytes
Binary file not shown.

src/build/neo4j/schema.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,12 @@ export const REL_TYPES: RelType[] = [
150150
{ type: "SUMMARY", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
151151
{ type: "PARAM_IN", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
152152
{ type: "PARAM_OUT", from: ["BodyNode"], to: ["BodyNode"], properties: { var: "string" } },
153+
// Inheritance, projected from the `extends_ids`/`implements_ids` node props (schema/v2/emit.ts) —
154+
// resolved-only: an unresolved (external/library) supertype never reaches here. A `to` of `Class`
155+
// covers TS's `implements SomeClass` (structural, not just interfaces); an interface may itself
156+
// `extends` a class's instance type, hence `EXTENDS` also allows an `Interface` source.
157+
{ type: "EXTENDS", from: ["Class", "Interface"], to: ["Class", "Interface"], properties: {} },
158+
{ type: "IMPLEMENTS", from: ["Class"], to: ["Interface", "Class"], properties: {} },
153159
];
154160

155161
// ----------------------------------------------------------------------------------------------

src/schema/v2/emit.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,11 +111,19 @@ function memberKey(sig: string, accessorKind?: string | null): string {
111111
return seg;
112112
}
113113

114+
/** A type's heritage signatures, resolved to `can://` ids once the whole tree walk registers them. */
115+
interface PendingHeritage {
116+
node: V2Type;
117+
extendsSigs: string[]; // class: the extended base class; interface: extended interface(s)
118+
implementsSigs: string[]; // class only: implemented interfaces
119+
}
120+
114121
/** State shared across the whole tree walk (edge-rewriting + gating). */
115122
interface SharedState {
116123
idBySig: Map<string, string>;
117124
collisions: string[];
118125
pendingCallees: Array<{ node: V2BodyNode; calleeSig: string | null }>; // backfilled at L2
126+
pendingHeritage: PendingHeritage[]; // resolved sig→id once the whole tree walk completes
119127
callableBySig: Map<string, V2Callable>; // locates each callable's node for the L3/L4 dataflow pass
120128
level: number;
121129
}
@@ -201,7 +209,14 @@ function toClass(c: TSClass, ctx: Ctx): V2Type {
201209
const fields: Record<string, V2Field> = {};
202210
for (const [name, a] of Object.entries(c.attributes ?? {}))
203211
fields[name] = fieldNode(id, name, (a as TSClassAttribute).span, a as unknown as Record<string, unknown>);
204-
return { ...carry(c as unknown as Record<string, unknown>), id, kind: "class", signature: c.signature, span: c.span, callables, fields };
212+
const node: V2Type = { ...carry(c as unknown as Record<string, unknown>), id, kind: "class", signature: c.signature, span: c.span, callables, fields };
213+
// `base_classes` is the union of extends + implements (schema.ts:231); subtract implements_types
214+
// to recover just the extended base class (0 or 1 — TS classes extend at most one class).
215+
if (c.base_classes.length) {
216+
const extendsSigs = c.base_classes.filter((s) => !c.implements_types.includes(s));
217+
ctx.pendingHeritage.push({ node, extendsSigs, implementsSigs: c.implements_types });
218+
}
219+
return node;
205220
}
206221

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

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

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

365+
// Resolve heritage sig → can:// id now that every first-party type is registered in idBySig
366+
// (independent of level: types are homed during the unconditional L1 walk above). Unresolved
367+
// (external/library) supertypes are dropped, never nulled — the "resolved-only" rule.
368+
for (const { node, extendsSigs, implementsSigs } of pendingHeritage) {
369+
const extendsIds = extendsSigs.map((s) => idBySig.get(s)).filter((x): x is string => x !== undefined);
370+
const implementsIds = implementsSigs.map((s) => idBySig.get(s)).filter((x): x is string => x !== undefined);
371+
if (extendsIds.length) node.extends_ids = extendsIds;
372+
if (implementsIds.length) node.implements_ids = implementsIds;
373+
}
374+
345375
// L2 — home the off-tree edge endpoints, backfill `callee`, rewrite the call graph.
346376
const dangling: string[] = [];
347377
if (level >= 2) {

src/schema/v2/model.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,11 @@ export interface V2Type extends V2Node {
102102
fields?: Record<string, V2Field>; // class attributes / interface properties / enum members
103103
types?: Record<string, V2Type>; // namespace: nested types
104104
functions?: Record<string, V2Callable>; // namespace: nested functions
105+
// Heritage: `base_classes`/`implements_types` (carried from v1) stay signature strings — the
106+
// resolved-id projection lives here, additively, for the Neo4j EXTENDS/IMPLEMENTS overlay.
107+
// External/library supertypes that never resolve to a first-party id are dropped, not nulled.
108+
extends_ids?: string[]; // resolved `can://` id(s) of the extended class/interface(s)
109+
implements_ids?: string[]; // resolved `can://` id(s) of implemented interfaces (classes only)
105110
}
106111

107112
export interface V2Callable extends V2Node {
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
/** A minimal first-party class hierarchy — exercises the Neo4j EXTENDS/IMPLEMENTS projection. */
2+
3+
export interface Shape {
4+
area(): number;
5+
}
6+
7+
export interface Labeled {
8+
readonly label: string;
9+
}
10+
11+
export class Rectangle implements Shape {
12+
constructor(
13+
protected readonly width: number,
14+
protected readonly height: number,
15+
) {}
16+
17+
area(): number {
18+
return this.width * this.height;
19+
}
20+
}
21+
22+
export class Square extends Rectangle implements Labeled {
23+
readonly label = "square";
24+
25+
constructor(side: number) {
26+
super(side, side);
27+
}
28+
}

test/neo4j-schema.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -91,3 +91,44 @@ describe("neo4j schema conformance", () => {
9191
expect(onDisk).toBe(fresh);
9292
});
9393
});
94+
95+
// ---- Class inheritance: EXTENDS/IMPLEMENTS (issue #33) ------------------------------------------
96+
// dataflow-app's src/hierarchy.ts is a minimal, first-party heritage fixture: `Rectangle implements
97+
// Shape`, `Square extends Rectangle implements Labeled`.
98+
99+
describe("neo4j inheritance edges (issue #33)", () => {
100+
test("EXTENDS and IMPLEMENTS are declared in the schema catalog", () => {
101+
expect(relByType.has("EXTENDS")).toBe(true);
102+
expect(relByType.has("IMPLEMENTS")).toBe(true);
103+
});
104+
105+
function nodeBySignature(signature: string) {
106+
return rows.nodes.find((n) => n.props.signature === signature);
107+
}
108+
109+
test("hierarchy.ts's first-party heritage projects the expected, non-dangling EXTENDS/IMPLEMENTS edges", () => {
110+
const square = nodeBySignature("src/hierarchy.Square");
111+
const rectangle = nodeBySignature("src/hierarchy.Rectangle");
112+
const shape = nodeBySignature("src/hierarchy.Shape");
113+
const labeled = nodeBySignature("src/hierarchy.Labeled");
114+
expect(square, "Square node").toBeDefined();
115+
expect(rectangle, "Rectangle node").toBeDefined();
116+
expect(shape, "Shape node").toBeDefined();
117+
expect(labeled, "Labeled node").toBeDefined();
118+
119+
const ext = rows.edges.filter((e) => e.type === "EXTENDS");
120+
const impl = rows.edges.filter((e) => e.type === "IMPLEMENTS");
121+
expect(ext.length).toBeGreaterThan(0);
122+
expect(impl.length).toBeGreaterThan(0);
123+
124+
expect(ext.some((e) => e.from.value === square!.value && e.to.value === rectangle!.value)).toBe(true);
125+
expect(impl.some((e) => e.from.value === rectangle!.value && e.to.value === shape!.value)).toBe(true);
126+
expect(impl.some((e) => e.from.value === square!.value && e.to.value === labeled!.value)).toBe(true);
127+
128+
const nodeValues = new Set(rows.nodes.map((n) => n.value));
129+
for (const e of [...ext, ...impl]) {
130+
expect(nodeValues.has(e.from.value), `dangling EXTENDS/IMPLEMENTS source ${e.from.value}`).toBe(true);
131+
expect(nodeValues.has(e.to.value), `dangling EXTENDS/IMPLEMENTS target ${e.to.value}`).toBe(true);
132+
}
133+
});
134+
});

test/schema-v2.test.ts

Lines changed: 121 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,58 @@ describe("schema v2 — L1 tree shape", () => {
170170
});
171171
});
172172

173+
// ---- Inheritance: heritage signatures resolve to can:// ids (issue #33) ------------------------
174+
// sample-app's models.ts is first-party heritage: Entity implements Identifiable; User extends
175+
// Entity implements Named; Robot implements Named (a second, unrelated implementer of Named).
176+
// This resolution is unconditional (independent of `-a` level) — it only needs idBySig, which the
177+
// unconditional L1 tree walk already populates — so it's exercised here even though `st` is L1.
178+
describe("schema v2 — inheritance resolves heritage signatures to can:// ids (issue #33)", () => {
179+
test("a class's extends/implements signatures resolve to their declaring node's id", () => {
180+
const types = st["src/models.ts"].types;
181+
const entity = types.Entity;
182+
const user = types.User;
183+
const robot = types.Robot;
184+
const identifiable = types.Identifiable;
185+
const named = types.Named;
186+
187+
expect(entity.extends_ids).toBeUndefined(); // no base class, only `implements`
188+
expect(entity.implements_ids).toEqual([identifiable.id]);
189+
190+
expect(user.extends_ids).toEqual([entity.id]);
191+
expect(user.implements_ids).toEqual([named.id]);
192+
193+
expect(robot.extends_ids).toBeUndefined();
194+
expect(robot.implements_ids).toEqual([named.id]);
195+
});
196+
197+
test("base_classes/implements_types (signature strings) are unchanged — additive, not replaced", () => {
198+
const entity = st["src/models.ts"].types.Entity as unknown as { base_classes: string[]; implements_types: string[] };
199+
const user = st["src/models.ts"].types.User as unknown as { base_classes: string[]; implements_types: string[] };
200+
const identifiableSig = st["src/models.ts"].types.Identifiable.signature;
201+
const entitySig = st["src/models.ts"].types.Entity.signature;
202+
const namedSig = st["src/models.ts"].types.Named.signature;
203+
204+
expect(entity.base_classes).toEqual([identifiableSig]);
205+
expect(entity.implements_types).toEqual([identifiableSig]);
206+
expect(user.base_classes).toEqual([entitySig, namedSig]);
207+
expect(user.implements_types).toEqual([namedSig]);
208+
});
209+
210+
test("an unresolved (external/library) supertype is dropped, not nulled", () => {
211+
// Every type in sample-app either has no heritage or fully first-party heritage, so there is no
212+
// dropped entry to observe directly here — instead assert the shape invariant the resolver
213+
// relies on: extends_ids/implements_ids, when present, are always non-empty arrays of can://
214+
// ids (never contain undefined/null placeholders for an unresolved signature).
215+
for (const t of Object.values(st["src/models.ts"].types)) {
216+
for (const ids of [t.extends_ids, t.implements_ids]) {
217+
if (ids === undefined) continue;
218+
expect(ids.length).toBeGreaterThan(0);
219+
for (const id of ids) expect(id.startsWith("can://")).toBe(true);
220+
}
221+
}
222+
});
223+
});
224+
173225
describe("schema v2 — L1 superset", () => {
174226
test("every v1 callable/type signature has a v2 id", () => {
175227
const v1sigs = new Set<string>();
@@ -511,6 +563,26 @@ function symbolIds(app: V2Application): Set<string> {
511563
return out;
512564
}
513565

566+
/** Every type node — classes/interfaces/enums/aliases/namespaces — recursing through nested scopes. */
567+
function allTypes(app: V2Application): V2Type[] {
568+
const out: V2Type[] = [];
569+
const walkCallable = (c: V2Callable): void => {
570+
for (const cc of Object.values(c.callables ?? {})) walkCallable(cc);
571+
for (const t of Object.values(c.types ?? {})) walkType(t);
572+
};
573+
const walkType = (t: V2Type): void => {
574+
out.push(t);
575+
for (const c of Object.values(t.callables ?? {})) walkCallable(c);
576+
for (const nested of Object.values(t.types ?? {})) walkType(nested);
577+
for (const fn of Object.values(t.functions ?? {})) walkCallable(fn);
578+
};
579+
for (const m of Object.values(app.application.symbol_table)) {
580+
for (const t of Object.values(m.types)) walkType(t);
581+
for (const c of Object.values(m.functions)) walkCallable(c);
582+
}
583+
return out;
584+
}
585+
514586
/** Every body-node key, namespaced by its owning callable id so bare local keys never collide. */
515587
function bodyKeys(app: V2Application): Set<string> {
516588
const out = new Set<string>();
@@ -602,6 +674,15 @@ function canNodeIds(app: V2Application): Set<string> {
602674
return ids;
603675
}
604676

677+
/** Every `call` body node whose `callee` resolved to an id — the JSON-side source of RESOLVES_TO. */
678+
function resolvesToCount(app: V2Application): number {
679+
let n = 0;
680+
for (const c of allCallables(app.application)) {
681+
for (const bn of Object.values(c.body)) if (typeof bn.callee === "string") n++;
682+
}
683+
return n;
684+
}
685+
605686
describe("neo4j ↔ json count parity — full depth (issue #27)", () => {
606687
test("node count: 1 :Application row + every :CanNode id", () => {
607688
expect(monoRows.nodes.length).toBe(1 + canNodeIds(monoApp4).size);
@@ -622,11 +703,50 @@ describe("neo4j ↔ json count parity — full depth (issue #27)", () => {
622703
// HAS_MODULE/DECLARES/HAS_METHOD/HAS_FIELD/HAS_BODY_NODE have no JSON edge-list counterpart —
623704
// they ARE the containment tree. Every node except :Application and the off-tree External/
624705
// AnonymousCallable homes gets exactly one incoming containment edge from its tree parent, so
625-
// their total must equal every other CanNode id.
706+
// their total must equal every other CanNode id. (RESOLVES_TO and EXTENDS/IMPLEMENTS ALSO have
707+
// no top-level JSON edge-list — they're sourced from a body node's `callee` and a type node's
708+
// `extends_ids`/`implements_ids` props respectively — but they are not containment either, so
709+
// they're deliberately excluded from this invariant too; see the dedicated tests below and the
710+
// exhaustive edge-accounting test that folds every relationship family back into one total.)
626711
const containment = ["HAS_MODULE", "DECLARES", "HAS_METHOD", "HAS_FIELD", "HAS_BODY_NODE"];
627712
const containmentEdges = containment.reduce((n, t) => n + relCount(monoRows, t), 0);
628713
const externalCount = Object.keys(monoApp4.application.external_symbols ?? {}).length;
629714
const synthCount = Object.keys(monoApp4.application.synthesized_callables ?? {}).length;
630715
expect(containmentEdges).toBe(canNodeIds(monoApp4).size - externalCount - synthCount);
631716
});
717+
718+
test("EXTENDS/IMPLEMENTS have no JSON edge-list (extends_ids/implements_ids node props are the source of truth); counts still match 1:1", () => {
719+
const types = allTypes(monoApp4);
720+
const extendsCount = types.reduce((n, t) => n + (t.extends_ids?.length ?? 0), 0);
721+
const implementsCount = types.reduce((n, t) => n + (t.implements_ids?.length ?? 0), 0);
722+
// sanity: dataflow-app's first-party hierarchy (src/hierarchy.ts) exercises both relationship
723+
// kinds — guards against a vacuous 0 === 0 pass if the fixture ever loses its heritage.
724+
expect(extendsCount).toBeGreaterThan(0);
725+
expect(implementsCount).toBeGreaterThan(0);
726+
expect(relCount(monoRows, "EXTENDS")).toBe(extendsCount);
727+
expect(relCount(monoRows, "IMPLEMENTS")).toBe(implementsCount);
728+
});
729+
730+
test("every projected edge is accounted for: typed overlay + containment + RESOLVES_TO + EXTENDS/IMPLEMENTS sums to the total", () => {
731+
// The exhaustive form of the parity gate: unlike the per-family tests above (which only prove
732+
// each family individually matches its JSON source), this proves nothing is left over — if
733+
// project.ts ever grows a new relationship family without this test learning about it, the two
734+
// sides diverge and the gate fails, instead of silently passing on an incomplete accounting.
735+
const typedOverlay =
736+
relCount(monoRows, "CALLS") +
737+
relCount(monoRows, "PARAM_IN") +
738+
relCount(monoRows, "PARAM_OUT") +
739+
relCount(monoRows, "CFG_NEXT") +
740+
relCount(monoRows, "CDG") +
741+
relCount(monoRows, "DDG") +
742+
relCount(monoRows, "SUMMARY");
743+
const containment = ["HAS_MODULE", "DECLARES", "HAS_METHOD", "HAS_FIELD", "HAS_BODY_NODE"].reduce(
744+
(n, t) => n + relCount(monoRows, t),
745+
0,
746+
);
747+
const resolvesTo = relCount(monoRows, "RESOLVES_TO");
748+
const heritage = relCount(monoRows, "EXTENDS") + relCount(monoRows, "IMPLEMENTS");
749+
expect(resolvesTo).toBe(resolvesToCount(monoApp4));
750+
expect(typedOverlay + containment + resolvesTo + heritage).toBe(monoRows.edges.length);
751+
});
632752
});

0 commit comments

Comments
 (0)