Skip to content
Merged
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
9 changes: 9 additions & 0 deletions .claude/SCHEMA_DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`** = `<callable-id>@<local>` 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`** = `<callable-id>@formal_in:<i>`, 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 |
4 changes: 2 additions & 2 deletions src/build/neo4j/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand All @@ -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<string, string> = {
Expand Down
6 changes: 5 additions & 1 deletion src/dataflow/attach.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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] } {
Expand Down Expand Up @@ -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);
}

// ----------------------------------------------------------------------------------------------
Expand Down
22 changes: 22 additions & 0 deletions src/schema/ids.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, { id?: string }>; 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}`); });
}
2 changes: 2 additions & 0 deletions src/schema/l1Body.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`, …
Expand Down Expand Up @@ -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 {
Expand Down
6 changes: 6 additions & 0 deletions src/schema/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,9 @@ export interface TSTypeParameter {

export interface TSCallableParameter {
name: string;
// `<callable-id>@formal_in:<i>` — 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;
Expand Down Expand Up @@ -160,6 +163,9 @@ export interface TSConfigAccess {
// ----------------------------------------------------------------------------------------------

export interface TSBodyNode {
// The GLOBAL ordinal id `<callable-id>@<local>` — 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)
Expand Down
72 changes: 72 additions & 0 deletions test/body-ids.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
Loading