diff --git a/schema.neo4j.json b/schema.neo4j.json index 0680b8e..2fb9054 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -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": [ diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 5077178..7c59e54 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -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; diff --git a/src/build/neo4j/rows.ts b/src/build/neo4j/rows.ts index 50195ff..f7900e9 100644 Binary files a/src/build/neo4j/rows.ts and b/src/build/neo4j/rows.ts differ diff --git a/src/build/neo4j/schema.ts b/src/build/neo4j/schema.ts index 5cad335..8c43efc 100644 --- a/src/build/neo4j/schema.ts +++ b/src/build/neo4j/schema.ts @@ -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: {} }, ]; // ---------------------------------------------------------------------------------------------- diff --git a/src/schema/v2/emit.ts b/src/schema/v2/emit.ts index db70c9e..3dfe880 100644 --- a/src/schema/v2/emit.ts +++ b/src/schema/v2/emit.ts @@ -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; 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; // locates each callable's node for the L3/L4 dataflow pass level: number; } @@ -201,7 +209,14 @@ function toClass(c: TSClass, ctx: Ctx): V2Type { const fields: Record = {}; for (const [name, a] of Object.entries(c.attributes ?? {})) fields[name] = fieldNode(id, name, (a as TSClassAttribute).span, a as unknown as Record); - return { ...carry(c as unknown as Record), id, kind: "class", signature: c.signature, span: c.span, callables, fields }; + const node: V2Type = { ...carry(c as unknown as Record), 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 { @@ -212,7 +227,11 @@ function toInterface(i: TSInterface, ctx: Ctx): V2Type { const fields: Record = {}; for (const [name, p] of Object.entries(i.properties ?? {})) fields[name] = fieldNode(id, name, (p as TSClassAttribute).span, p as unknown as Record); - return { ...carry(i as unknown as Record), id, kind: "interface", signature: i.signature, span: i.span, callables, fields }; + const node: V2Type = { ...carry(i as unknown as Record), 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 { @@ -332,8 +351,9 @@ export function toV2Detailed(app: TSApplication, opts: AnalysisOptions): ToV2Res const idBySig = new Map(); const collisions: string[] = []; const pendingCallees: Array<{ node: V2BodyNode; calleeSig: string | null }> = []; + const pendingHeritage: PendingHeritage[] = []; const callableBySig = new Map(); - 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 = {}; @@ -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) { diff --git a/src/schema/v2/model.ts b/src/schema/v2/model.ts index 493fee6..ecf04f6 100644 --- a/src/schema/v2/model.ts +++ b/src/schema/v2/model.ts @@ -102,6 +102,11 @@ export interface V2Type extends V2Node { fields?: Record; // class attributes / interface properties / enum members types?: Record; // namespace: nested types functions?: Record; // 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 { diff --git a/test/fixtures/dataflow-app/src/hierarchy.ts b/test/fixtures/dataflow-app/src/hierarchy.ts new file mode 100644 index 0000000..79da20e --- /dev/null +++ b/test/fixtures/dataflow-app/src/hierarchy.ts @@ -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); + } +} diff --git a/test/neo4j-schema.test.ts b/test/neo4j-schema.test.ts index 389e766..c22e139 100644 --- a/test/neo4j-schema.test.ts +++ b/test/neo4j-schema.test.ts @@ -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); + } + }); +}); diff --git a/test/schema-v2.test.ts b/test/schema-v2.test.ts index d1ccbdf..830f838 100644 --- a/test/schema-v2.test.ts +++ b/test/schema-v2.test.ts @@ -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(); @@ -511,6 +563,26 @@ function symbolIds(app: V2Application): Set { 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 { const out = new Set(); @@ -602,6 +674,15 @@ function canNodeIds(app: V2Application): Set { 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); @@ -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); + }); });