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 1d26679..f0c29cb 100644 Binary files a/src/build/neo4j/project.ts and b/src/build/neo4j/project.ts differ diff --git a/src/core.ts b/src/core.ts index 7c00ee9..85e768a 100644 --- a/src/core.ts +++ b/src/core.ts @@ -32,6 +32,7 @@ export function analyze(opts: AnalysisOptions): TSApplication { symbol_table, call_graph, external_symbols: cg.external_symbols, + synthesized_callables: cg.synthesized_callables, }; saveCache(cacheDir, { symbol_table, call_graph }); return app; diff --git a/src/schema/schema.ts b/src/schema/schema.ts index 60a5c60..056e950 100644 --- a/src/schema/schema.ts +++ b/src/schema/schema.ts @@ -379,10 +379,22 @@ export interface TSExternalSymbol { module: string; // the import/require specifier, e.g. "node:fs", "express", "@scope/pkg" } +// A first-party anonymous callback that Jelly resolves as a call-graph endpoint but the symbol +// table never names (the canonicalizer returns null for anonymous functions). The map key IS the +// synthesized signature `:`, 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"])]);