From bd56f0a1aca536c5058ce33ec6dedd90a17e9c66 Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Wed, 24 Jun 2026 15:38:36 -0400 Subject: [PATCH] fix(call-graph): materialize Jelly's anonymous-callback nodes (closes #13) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Jelly resolves edges to synthesized anonymous-callback signatures (:) that the symbol table never names. Their endpoints had no node, so in analysis.json the edge referenced an absent signature and in Neo4j the MATCH-based Cypher writer silently dropped the relationship. - jellyProvider now returns a synthesized_callables map (built from data it already has at synth time), filtered to signatures an edge actually references. - New TSApplication.synthesized_callables (sibling of external_symbols); the union merges it; tsc returns an empty map. - project.ts mints a thin :Symbol:AnonymousCallable node per entry plus a DECLARES edge from the host symbol (or owning module) so it stays in the wiped subgraph. - New AnonymousCallable label in the catalog; schema.neo4j.json regenerated. No REL_TYPES change — CALLS/DECLARES already accept Symbol targets by merge-label. Verified end-to-end on the fixture: union analysis.json has 0 edges with a missing-node endpoint, and graph.cypher emits the AnonymousCallable node. --- schema.neo4j.json | 13 ++++++++ src/build/neo4j/catalog.ts | 15 +++++++++ src/build/neo4j/project.ts | Bin 18784 -> 19789 bytes src/core.ts | 1 + src/schema/schema.ts | 12 +++++++ src/semantic_analysis/callGraph.ts | 6 +++- src/semantic_analysis/jellyProvider.ts | 43 ++++++++++++++++++++++--- src/semantic_analysis/provider.ts | 3 +- test/synthesized-nodes.test.ts | 40 +++++++++++++++++++++++ test/union-provider.test.ts | 14 +++++++- 10 files changed, 139 insertions(+), 8 deletions(-) create mode 100644 test/synthesized-nodes.test.ts diff --git a/schema.neo4j.json b/schema.neo4j.json index 232c28e..192146f 100644 --- a/schema.neo4j.json +++ b/schema.neo4j.json @@ -174,6 +174,19 @@ "module": "string" } }, + { + "label": "AnonymousCallable", + "mergeLabel": "Symbol", + "key": "signature", + "properties": { + "signature": "string", + "name": "string", + "path": "string", + "start_line": "integer", + "start_column": "integer", + "_module": "string" + } + }, { "label": "Package", "mergeLabel": "Package", diff --git a/src/build/neo4j/catalog.ts b/src/build/neo4j/catalog.ts index 713a32e..ddb4ac0 100644 --- a/src/build/neo4j/catalog.ts +++ b/src/build/neo4j/catalog.ts @@ -194,6 +194,21 @@ export const NODE_LABELS: NodeLabel[] = [ key: "signature", properties: { signature: "string", name: "string", module: "string" }, }, + { + // A first-party anonymous callback Jelly resolves as a call endpoint but the symbol table never + // names. Thin (no code/params) — the signature carries identity; DECLARES links it to its host. + label: "AnonymousCallable", + mergeLabel: "Symbol", + key: "signature", + properties: { + signature: "string", + name: "string", + path: "string", + start_line: "integer", + start_column: "integer", + _module: "string", + }, + }, { label: "Package", mergeLabel: "Package", key: "name", properties: { name: "string" } }, { label: "Decorator", diff --git a/src/build/neo4j/project.ts b/src/build/neo4j/project.ts index 1d266793fdff9044af5463cd2832149c749ee553..f0c29cb2279c6f47952220fe24c5197c45f89156 100644 GIT binary patch delta 1004 zcmZuw&2G~`5LS=ng2ajAhdE$Nu3N+fLXs*fJ%B1zq9=r^c5P3brR!a5cilFiDm(+_ z#8V*g9Nc&S1TVs@ou&cd(|YEc`R3=FpPx3qf7$r@dvh_-M$c>0Iut^wToh*xuSlsm zEIFgjsf6f#AYC>_YVYvt<7d>ula)fr_dUeVq=q&nIXDA9BWdpd+uPWi92^{h%8EUu z(T_Yzp({m*rE`mx@bK}IB*O$R_V*4ZulIW_rqgcDtt!TTS+dq zRKx;JldZLK5|$uxMJ`DaeBfl)cke&AUDDpt9UZ5LRvkx75qKKgYKlWt__U~Lz}#7Q zq)THX{H!yE$Bof>Vf{%rt$@G%T>=*9O+3oB)?N6z;U>*VKihxOUnR-&j=Y~p(Rz$R zxtS8qztkmN9!}$EFpU0!bxR*1pvO4c!EP7gc*i}ArE#tn}-Cui|W*(f|}n4*xFQ:`, so an edge `source`/ +// `target` byte-matches it just like a real `Callable.signature` or `TSExternalSymbol.signature`. +export interface TSSynthesizedCallable { + name: string; // display name — always ""; the signature carries the precise identity + path: string; // owning module key (project-relative POSIX path WITH extension) + start_line: number; + start_column: number; +} + export interface TSApplication { symbol_table: Record; call_graph: TSCallEdge[]; external_symbols: Record; + synthesized_callables: Record; } // ============================================================================================== diff --git a/src/semantic_analysis/callGraph.ts b/src/semantic_analysis/callGraph.ts index d77953c..c85fb9a 100644 --- a/src/semantic_analysis/callGraph.ts +++ b/src/semantic_analysis/callGraph.ts @@ -18,6 +18,7 @@ import { type TSExternalSymbol, type TSModule, type TSNamespace, + type TSSynthesizedCallable, } from "../schema"; import { resolveCalleeSignature } from "../schema"; import type { Logger } from "../utils"; @@ -31,6 +32,9 @@ interface ClassMeta { export interface CallGraphResult { edges: TSCallEdge[]; external_symbols: Record; + // Anonymous callbacks resolved as edge endpoints that the symbol table doesn't name. Empty for + // the tsc resolver (its edges are gated to real symbol-table signatures); populated by Jelly. + synthesized_callables: Record; } export function buildCallGraph( @@ -165,7 +169,7 @@ export function buildCallGraph( `call graph (tsc): ${resolved} resolved, ${rtaCount} RTA-expanded, ${phantomCount} phantom (external), ` + `${unresolved} unresolved, ${edges.size} unique edges, ${Object.keys(external_symbols).length} external symbols`, ); - return { edges: [...edges.values()], external_symbols }; + return { edges: [...edges.values()], external_symbols, synthesized_callables: {} }; } /** Concrete, instantiated subtypes of `declType` that declare an override of `methodName`. */ diff --git a/src/semantic_analysis/jellyProvider.ts b/src/semantic_analysis/jellyProvider.ts index 71d61aa..d7f6af7 100644 --- a/src/semantic_analysis/jellyProvider.ts +++ b/src/semantic_analysis/jellyProvider.ts @@ -35,7 +35,14 @@ import { createRequire } from "node:module"; import * as os from "node:os"; import * as path from "node:path"; import { Node, type SourceFile } from "ts-morph"; -import { CALL_DEP, computeSignatureForDecl, type TSCallEdge, type TSExternalSymbol } from "../schema"; +import { + CALL_DEP, + computeSignatureForDecl, + fileKeyOf, + type TSCallEdge, + type TSExternalSymbol, + type TSSynthesizedCallable, +} from "../schema"; import type { CallGraphResult } from "./callGraph"; import { type ExternalIndex, buildExternalIndex, resolvePhantom } from "./phantoms"; import type { CallGraphContext, CallGraphProvider } from "./provider"; @@ -216,7 +223,7 @@ export const jellyProvider: CallGraphProvider = { if (entryFiles.length === 0) { ctx.log.info("call graph (jelly): no first-party source files to analyze"); - return { edges: [], external_symbols: {} }; + return { edges: [], external_symbols: {}, synthesized_callables: {} }; } const cg = runJelly(ctx, entryFiles); @@ -229,6 +236,20 @@ export const jellyProvider: CallGraphProvider = { const depMeta = new Map(); let synthesized = 0; let unresolved = 0; + + // Anonymous callbacks get a synthesized signature with no symbol-table node; remember their + // location so the projection can materialize a node and the edge won't dangle (issue #13). + const synthesizedCallables: Record = {}; + const recordIfSynth = (fn: Node, sig: string, synth: boolean): void => { + if (!synth || synthesizedCallables[sig]) return; + const { line, column } = fn.getSourceFile().getLineAndColumnAtPos(fn.getStart()); + synthesizedCallables[sig] = { + name: "", + path: fileKeyOf(fn.getSourceFile().getFilePath(), ctx.root).fileKey, + start_line: line, + start_column: column, + }; + }; for (const [id, loc] of Object.entries(cg.functions)) { const [fileIdx, sl, sc] = loc.split(":").map(Number); const rel = cg.files[fileIdx]; @@ -262,6 +283,7 @@ export const jellyProvider: CallGraphProvider = { id2sig.set(id, sig); firstPartyIds.add(id); if (synth) synthesized++; + recordIfSynth(fn, sig, synth); } const external_symbols: Record = {}; @@ -316,7 +338,8 @@ export const jellyProvider: CallGraphProvider = { if (!callNode) continue; const callerFn = climbToFunctionLike(callNode); if (!callerFn) continue; // top-level call, no enclosing function - const callerSig = signatureFor(callerFn, ctx.root).sig; + const { sig: callerSig, synth: callerSynth } = signatureFor(callerFn, ctx.root); + recordIfSynth(callerFn, callerSig, callerSynth); const ph = resolvePhantom(callNode, extIndexFor(sf)); let sig: string; if (ph) { @@ -354,11 +377,21 @@ export const jellyProvider: CallGraphProvider = { if (!(srcFP && !tgtFP)) dropped++; // dep→dep / unresolved (first-party→dep is counted in 2a) } + // Keep only synthesized callables that an edge actually references — no orphan nodes. + const referenced = new Set(); + for (const e of edges.values()) { + referenced.add(e.source); + referenced.add(e.target); + } + const synthesized_callables: Record = {}; + for (const [sig, sc] of Object.entries(synthesizedCallables)) if (referenced.has(sig)) synthesized_callables[sig] = sc; + ctx.log.info( `call graph (jelly): ${Object.keys(cg.functions).length} jelly funcs, ${firstPartyIds.size} first-party ` + - `(${synthesized} synthesized), ${Object.keys(external_symbols).length} external symbols, ${unresolved} unresolved, ` + + `(${synthesized} synthesized, ${Object.keys(synthesized_callables).length} materialized), ` + + `${Object.keys(external_symbols).length} external symbols, ${unresolved} unresolved, ` + `${edges.size} edges (${boundary} library-boundary), ${dropped} dropped`, ); - return { edges: [...edges.values()], external_symbols }; + return { edges: [...edges.values()], external_symbols, synthesized_callables }; }, }; diff --git a/src/semantic_analysis/provider.ts b/src/semantic_analysis/provider.ts index bffea78..f349a5f 100644 --- a/src/semantic_analysis/provider.ts +++ b/src/semantic_analysis/provider.ts @@ -60,7 +60,8 @@ export function mergeCallGraphs(a: CallGraphResult, b: CallGraphResult): CallGra } const external_symbols: Record = { ...b.external_symbols, ...a.external_symbols }; - return { edges: [...byKey.values()], external_symbols }; + const synthesized_callables = { ...b.synthesized_callables, ...a.synthesized_callables }; + return { edges: [...byKey.values()], external_symbols, synthesized_callables }; } /** Count how the two edge sets overlap — preserves the old `both`-mode diagnostic. */ diff --git a/test/synthesized-nodes.test.ts b/test/synthesized-nodes.test.ts new file mode 100644 index 0000000..21fe613 --- /dev/null +++ b/test/synthesized-nodes.test.ts @@ -0,0 +1,40 @@ +/** + * Issue #13: Jelly's synthesized anonymous-callback signatures must materialize as nodes, so their + * CALLS edges resolve instead of being silently dropped by the MATCH-based Cypher writer. + */ +import { describe, expect, test } from "bun:test"; +import { project } from "../src/build/neo4j"; +import { CALL_DEP, type TSApplication, type TSCallable, type TSModule } from "../src/schema"; + +const ANON = "src/x.foo:<3:10>"; + +const callable = (signature: string, name: string): TSCallable => ({ signature, name }) as unknown as TSCallable; + +const app: TSApplication = { + symbol_table: { "src/x.ts": { functions: { foo: callable("src/x.foo", "foo") } } as unknown as TSModule }, + call_graph: [{ source: "src/x.foo", target: ANON, type: CALL_DEP, weight: 1, provenance: ["jelly"], tags: {} }], + external_symbols: {}, + synthesized_callables: { [ANON]: { name: "", path: "src/x.ts", start_line: 3, start_column: 10 } }, +}; + +describe("synthesized anonymous-callable nodes", () => { + const rows = project(app, "t"); + + test("emits an :Symbol:AnonymousCallable node for the synthesized signature", () => { + const n = rows.nodes.find((n) => n.value === ANON); + expect(n?.labels[0]).toBe("Symbol"); + expect(n?.labels).toContain("AnonymousCallable"); + expect(n?.props.start_line).toBe(3); + }); + + test("the CALLS edge to the anonymous callable survives (was silently dropped before)", () => { + const e = rows.edges.find((e) => e.type === "CALLS" && e.to.value === ANON); + expect(e?.from.value).toBe("src/x.foo"); + }); + + test("a DECLARES edge links the host symbol to it (keeps it in the wiped subgraph)", () => { + const e = rows.edges.find((e) => e.type === "DECLARES" && e.to.value === ANON); + expect(e?.from.value).toBe("src/x.foo"); + expect(e?.from.label).toBe("Symbol"); + }); +}); diff --git a/test/union-provider.test.ts b/test/union-provider.test.ts index a624160..d9f0cc9 100644 --- a/test/union-provider.test.ts +++ b/test/union-provider.test.ts @@ -17,9 +17,14 @@ const edge = (source: string, target: string, provenance: string[], extra: Parti ...extra, }); -const result = (edges: TSCallEdge[], external: CallGraphResult["external_symbols"] = {}): CallGraphResult => ({ +const result = ( + edges: TSCallEdge[], + external: CallGraphResult["external_symbols"] = {}, + synthesized: CallGraphResult["synthesized_callables"] = {}, +): CallGraphResult => ({ edges, external_symbols: external, + synthesized_callables: synthesized, }); describe("mergeCallGraphs", () => { @@ -51,6 +56,13 @@ describe("mergeCallGraphs", () => { expect(merged.external_symbols["pkg.foo"].name).toBe("foo"); // base (tsc) wins }); + test("unions synthesized (anonymous-callback) callables from both", () => { + const tsc = result([]); + const jelly = result([], {}, { "src/x.foo:<3:10>": { name: "", path: "src/x.ts", start_line: 3, start_column: 10 } }); + const merged = mergeCallGraphs(tsc, jelly); + expect(Object.keys(merged.synthesized_callables)).toEqual(["src/x.foo:<3:10>"]); + }); + test("does not mutate the input results", () => { const tsc = result([edge("a", "b", ["tsc"])]); const jelly = result([edge("a", "b", ["jelly"])]);