From aa03f3fdace24d6675ebb65077eea7c55c661212 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Sun, 6 Sep 2026 00:21:53 -0400 Subject: [PATCH] feat(schema): body nodes and parameters carry their id in analysis.json MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit codeanalyzer-python 1.4.1 (#180, design #176) stamps `id` on every body node — `@`, the same value the Neo4j projection merges the node on — and on every parameter — `@formal_in:`, the L4 formal_in vertex that carries it, emitted as a forward reference below level 4. The shared vocabulary promises both. TypeScript emitted neither, and the composition rule lived in two private `fq` copies (build/neo4j/project.ts, dataflow/attach.ts). `globalOrdinal` in schema/ids.ts is now the single definition; both `fq`s delegate to it. `stampBodyIds` runs after each body emitter: at the end of the L1 emitter per callable, and once over every callable at the end of applyDataflow (L4 writes actual_in/out into CALLER bodies, so a per-callee stamp would miss them). Two agreement tests pin the property against the OTHER projection rather than a hand-typed string: every body node's id equals the projected :TSBodyNode key for that callable, and every parameters[i].id equals the @formal_in:i vertex id with `of` naming the parameter. Mutation-checked: skipping the stamp and changing the separator each fail the tests. Additive; SCHEMA_VERSION stays 2.0.0 (#144). schema.neo4j.json is unchanged — :TSBodyNode already carried id as its key, and parameters are not graph nodes. --- .claude/SCHEMA_DECISIONS.md | 9 +++++ src/build/neo4j/project.ts | 4 +-- src/dataflow/attach.ts | 6 +++- src/schema/ids.ts | 22 ++++++++++++ src/schema/l1Body.ts | 2 ++ src/schema/schema.ts | 6 ++++ test/body-ids.test.ts | 72 +++++++++++++++++++++++++++++++++++++ 7 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 test/body-ids.test.ts diff --git a/.claude/SCHEMA_DECISIONS.md b/.claude/SCHEMA_DECISIONS.md index f8ef755..0b4cf60 100644 --- a/.claude/SCHEMA_DECISIONS.md +++ b/.claude/SCHEMA_DECISIONS.md @@ -154,3 +154,12 @@ same chain crossing one call boundary via the SDG param/summary edges). Neither an unresolved read is `reason: "non-literal"` (key never closes on one literal) or `reason: "undefined-key"` (a literal key matching no declared `ConfigKey`) — first-class in `config_reads`, never silently dropped. + +## Body-node and parameter ids (2026-09-06, #164 — python #176/#180 parity) + +| # | Concept | Decision | Rationale | +|---|---|---|---| +| I1 | **`TSBodyNode.id`** = `@` on every body node, every level | the same string the Neo4j projection merges `:TSBodyNode` on, stamped into the tree by `stampBodyIds` after each emitter (L1 `populateL1Body`; L3/L4 at the end of `applyDataflow`) | a JSON consumer names a statement without recomposing the join key; python's `vocabulary.md` now promises it | +| I2 | **`TSCallableParameter.id`** = `@formal_in:`, present at EVERY level | a forward reference below L4 to the vertex that carries the parameter; at L4 `body["@formal_in:i"].id === parameters[i].id` and `.of === parameters[i].name` | python emits it at every level for the same reason; consumers key parameter flow on it before L4 exists | +| I3 | **One definition**: `globalOrdinal` in `src/schema/ids.ts`; `project.ts` and `attach.ts` delegate | the rule used to live in two private `fq` copies | two copies of a join key drift; the agreement test pins JSON `id` == graph merge key | +| I4 | **`SCHEMA_VERSION` unmoved** (2.0.0) | additive fields; no label/relationship/property change in the graph | #144: one version until every analyzer re-baselines together | diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 59f75c9..c5f5d2c 100644 --- a/src/build/neo4j/project.ts +++ b/src/build/neo4j/project.ts @@ -11,7 +11,7 @@ */ import type { TSAnalysis, TSApplication, TSBodyNode, TSCallable, TSDecorator, TSEntrypoint, TSEntrypointReport, TSField, TSModule, TSType } from "../../schema"; -import { purlNpm } from "../../schema/ids"; +import { globalOrdinal, purlNpm } from "../../schema/ids"; import { SCHEMA_VERSION } from "./schema"; import { type GraphRows, type NodeRef, type Props, RowBuilder, prune } from "./rows"; @@ -21,7 +21,7 @@ const ref = (id: string): NodeRef => ({ label: CAN, keyProp: "id", value: id }); /** Fully-qualify a callable-local body key (mirrors dataflow.ts § fq — the SDK-shared rule). */ function fq(callableId: string, localKey: string): string { - return localKey.startsWith("@") ? `${callableId}${localKey}` : `${callableId}@${localKey}`; + return globalOrdinal(callableId, localKey); // single definition lives in schema/ids.ts (#164) } const KIND_LABEL: Record = { diff --git a/src/dataflow/attach.ts b/src/dataflow/attach.ts index 11f59b0..905d4fd 100644 --- a/src/dataflow/attach.ts +++ b/src/dataflow/attach.ts @@ -19,6 +19,7 @@ import type { CfgEdge, GraphNode, PdgEdge, ProgramGraphs } from "../schema/graphs"; import type { TSApplication, TSCallable, TSParamEdge } from "../schema"; +import { globalOrdinal, stampBodyIds } from "../schema/ids"; interface LocalIds { canId: string; @@ -69,7 +70,7 @@ function l3(li: LocalIds, nodeId: number): string { * id, then matches the remainder OR the remainder without its leading `@`. */ function fq(callableId: string, bodyKey: string): string { - return bodyKey.startsWith("@") ? `${callableId}${bodyKey}` : `${callableId}@${bodyKey}`; + return globalOrdinal(callableId, bodyKey); // single definition lives in schema/ids.ts (#164) } function spanOf(n: GraphNode): { start: [number, number]; end: [number, number]; bytes: [number, number] } { @@ -250,6 +251,9 @@ export function applyDataflow( } if (level >= 4) emitL4(root, pg, info); + // #164: every emitter above wrote body nodes (L3 statements; L4 formal/actual vertices, some into + // CALLER bodies) — stamp once over every callable, idempotently, so `id` is present on all of them. + for (const c of callableBySig.values()) stampBodyIds(c); } // ---------------------------------------------------------------------------------------------- diff --git a/src/schema/ids.ts b/src/schema/ids.ts index 086dc56..cce0d80 100644 --- a/src/schema/ids.ts +++ b/src/schema/ids.ts @@ -83,3 +83,25 @@ export function memberKey(sig: string, accessorKind?: string | null): string { if (accessorKind === "setter") return `${seg}#set`; return seg; } + +/** + * The GLOBAL ordinal id of a body node from its LOCAL key (#164; python #176/#180 parity): + * synthetic keys (`@entry`, `@formal_in:0`) already carry the `@`; positional keys (`15:2`, + * `15:2/actual_in:0`) get one. This is the :TSBodyNode merge key AND `TSBodyNode.id` — the one + * implementation both projections share, so a JSON-side node and its graph node join on one string + * without recomposing the id. + */ +export function globalOrdinal(callableId: string, localKey: string): string { + return localKey.startsWith("@") ? `${callableId}${localKey}` : `${callableId}@${localKey}`; +} + +/** + * Stamp `id` on every body node and every parameter of one callable (#164). Idempotent; each body + * emitter calls it after writing its nodes (L1 `populateL1Body`, L3/L4 `applyDataflow`). + * `parameters[i].id` is the L4 `formal_in` vertex that carries the parameter — a forward + * reference below level 4, by design. + */ +export function stampBodyIds(c: { id: string; body?: Record; parameters?: Array<{ id?: string }> }): void { + for (const [key, node] of Object.entries(c.body ?? {})) node.id = globalOrdinal(c.id, key); + (c.parameters ?? []).forEach((p, i) => { p.id = globalOrdinal(c.id, `@formal_in:${i}`); }); +} diff --git a/src/schema/l1Body.ts b/src/schema/l1Body.ts index 2dddc70..b222c7f 100644 --- a/src/schema/l1Body.ts +++ b/src/schema/l1Body.ts @@ -14,6 +14,7 @@ import type { AnalysisInternal, TSBodyNode, TSCallable, TSCallsite, TSModule } from "./schema"; import { forEachCallable } from "./schema"; +import { stampBodyIds } from "./ids"; /** * The body key of each call site, in recording order: `line:col`, disambiguated `/2`, `/3`, … @@ -77,6 +78,7 @@ function resetCallable(c: TSCallable): void { delete c.cdg; delete c.ddg; delete c.summary; + stampBodyIds(c); // #164: ids ride the tree, not just the projection } export function populateL1Body(app: AnalysisInternal): void { diff --git a/src/schema/schema.ts b/src/schema/schema.ts index c1f35c5..da9d3cd 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -96,6 +96,9 @@ export interface TSTypeParameter { export interface TSCallableParameter { name: string; + // `@formal_in:` — the L4 formal_in vertex carrying this parameter (#164; python + // #176 parity). Stamped per-run by stampBodyIds; a forward reference below level 4. + id?: string; type?: string; default_value?: string; is_optional: boolean; @@ -160,6 +163,9 @@ export interface TSConfigAccess { // ---------------------------------------------------------------------------------------------- export interface TSBodyNode { + // The GLOBAL ordinal id `@` — the same value :TSBodyNode merges on (#164; + // python #176 parity). Stamped per-run by stampBodyIds after each body emitter writes. + id?: string; kind: string; // "call" | "config_access" | "statement" | "entry" | "exit" | "formal_in" | "actual_in" | … span?: TSSpan; callee?: string | null; // `call` nodes: null at L1, refined to an id at L2 (the one sanctioned null) diff --git a/test/body-ids.test.ts b/test/body-ids.test.ts new file mode 100644 index 0000000..879324e --- /dev/null +++ b/test/body-ids.test.ts @@ -0,0 +1,72 @@ +/** + * Body-node and parameter ids in analysis.json (#164; python #176/#180 parity). + * + * Two agreement properties, each pinned against the OTHER projection rather than against a + * hand-typed string: every body node's `id` is exactly the key the Neo4j projection merges its + * `:TSBodyNode` on, and every `parameters[i].id` is exactly the id of the L4 `@formal_in:i` + * vertex that carries it. If either drifted, JSON consumers would compose a key that no graph + * node has. + */ +import { describe, expect, test } from "bun:test"; +import * as path from "node:path"; +import { analyze } from "../src/core"; +import { project } from "../src/build/neo4j"; +import { forEachCallable } from "../src/schema"; +import { globalOrdinal } from "../src/schema/ids"; +import type { AnalysisOptions } from "../src/options"; +import type { TSApplication, TSCallable } from "../src/schema"; + +const FIXTURE = path.resolve(import.meta.dir, "fixtures/dataflow-app"); +const opts = (analysisLevel: number) => + ({ input: FIXTURE, appName: "bi", analysisLevel, eager: true, noBuild: true, emit: "json", + graphs: ["cfg", "dfg", "pdg", "sdg"], graphFieldDepth: 3, jobs: 1, skipTests: true, phantoms: true, entrypointRules: null }) as unknown as AnalysisOptions; +const rootOf = (r: { application: unknown }) => (r.application as { application: TSApplication }).application; +const callables = (root: TSApplication): TSCallable[] => { const out: TSCallable[] = []; for (const m of Object.values(root.symbol_table)) forEachCallable(m, (c) => out.push(c)); return out; }; + +describe("body-node and parameter ids (#164)", () => { + test("L1: every call node and every parameter already carries its id", async () => { + const cs = callables(rootOf(await analyze(opts(1)))); + let bodyNodes = 0, params = 0; + for (const c of cs) { + for (const [k, n] of Object.entries(c.body ?? {})) { bodyNodes++; expect(n.id).toBe(globalOrdinal(c.id, k)); } + c.parameters.forEach((p, i) => { params++; expect(p.id).toBe(`${c.id}@formal_in:${i}`); }); + } + expect(bodyNodes).toBeGreaterThan(0); + expect(params).toBeGreaterThan(0); + }); + + test("L3: body-node ids equal the projected :TSBodyNode merge keys, per callable", async () => { + const res = await analyze(opts(3)); + const root = rootOf(res); + const rows = project(res.application); + let checked = 0; + for (const c of callables(root)) { + const body = Object.entries(c.body ?? {}); + if (!body.length) continue; + const emitted = new Set(rows.nodes.filter((n) => n.labels.includes("TSBodyNode") && n.value.startsWith(`${c.id}@`)).map((n) => n.value)); + expect(new Set(body.map(([, n]) => n.id))).toEqual(emitted); + for (const [k, n] of body) { + expect(n.id).toBe(globalOrdinal(c.id, k)); + if (n.kind === "call" && n.callee) expect(n.id).not.toBe(n.callee); // a call's id is never its target + } + checked++; + } + expect(checked).toBeGreaterThan(3); + }); + + test("L4: parameters[i].id names the @formal_in:i vertex, in list order, and the vertex agrees", async () => { + const cs = callables(rootOf(await analyze(opts(4)))); + let withVertices = 0; + for (const fn of cs) { + fn.parameters.forEach((p, i) => { + expect(p.id).toBe(`${fn.id}@formal_in:${i}`); + const vertex = fn.body?.[`@formal_in:${i}`]; + if (!vertex) return; // L4 only materialises formal_in for callables with a CFG + expect(vertex.id).toBe(p.id); + expect(vertex.of).toBe(p.name); + withVertices++; + }); + } + expect(withVertices).toBeGreaterThan(0); + }); +});