diff --git a/README.md b/README.md index 7e45a78..5646c55 100644 --- a/README.md +++ b/README.md @@ -23,10 +23,11 @@ analysis into a **Neo4j property graph**. It is the TypeScript backend behind [Python](https://github.com/codellm-devkit/codeanalyzer-python) and [Java](https://github.com/codellm-devkit/codeanalyzer-java) siblings. -The call graph defaults to the TypeScript compiler's resolver, but the `cants` binary also embeds +By default the call graph is the **union** of two backends: the TypeScript compiler's resolver and [Jelly](https://github.com/cs-au-dk/jelly) — a flow-based analyzer that resolves higher-order and -callback edges the resolver misses — as an experimental backend (`--call-graph-provider jelly`, or -`both` to diff them). No extra install is needed; Jelly ships inside the binary. +callback edges the resolver misses, embedded in the `cants` binary (no extra install). Merged edges +keep a `provenance` tag (`tsc` / `jelly`), so you can still tell the two apart. Pass `--tsc-only` to +drop Jelly and run the resolver alone, or `--call-graph-provider jelly` for Jelly alone. ## Table of Contents @@ -53,8 +54,9 @@ callback edges the resolver misses — as an experimental backend (`--call-graph methods, variables, decorators, and JSDoc, with precise source spans. - **Call graph** — the TypeScript compiler's resolver plus Rapid Type Analysis (RTA), with **phantom (external) nodes** for calls into imported libraries and Node builtins. -- **Pluggable call-graph backend** — the `tsc` resolver by default, the embedded - [Jelly](https://github.com/cs-au-dk/jelly) flow analyzer, or `both` to compare edge sets. +- **Pluggable call-graph backend** — the `union` of the `tsc` resolver and the embedded + [Jelly](https://github.com/cs-au-dk/jelly) flow analyzer by default (`--tsc-only` for the resolver + alone, `--call-graph-provider jelly` for Jelly alone). - **Neo4j output** — project the analysis into a labeled property graph: a self-contained `graph.cypher` snapshot, or an **incremental** push to a live database over Bolt. - **Versioned schema** — a machine-readable, version-stamped Neo4j schema contract @@ -169,8 +171,11 @@ Options: node_modules) --no-phantoms disable phantom (external) nodes for imported/required library calls - --call-graph-provider call-graph backend: tsc (default) | jelly | - both (default: "tsc") + --call-graph-provider call-graph backend: union (default, tsc ∪ + jelly) | tsc | jelly | both (deprecated alias + of union) (default: "union") + --tsc-only use the tsc resolver only — opt out of Jelly + edges (overrides --call-graph-provider) -c, --cache-dir cache/intermediate directory -v, --verbose increase verbosity (repeatable) -h, --help display help for command @@ -198,9 +203,9 @@ Options: cants --input ./my-ts-project --target-files src/a.ts src/b.ts ``` -4. **Compare call-graph backends:** +4. **Resolver-only call graph (opt out of Jelly):** ```sh - cants --input ./my-ts-project --call-graph-provider both + cants --input ./my-ts-project --tsc-only ``` 5. **Force a clean rebuild with a custom cache directory:** diff --git a/src/cli.ts b/src/cli.ts index 070f252..fba5dd4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -43,7 +43,12 @@ export function buildProgram(): Command { .option("--lazy", "reuse the cache (default)") .option("--no-build", "skip dependency materialization (use a prepared node_modules)") .option("--no-phantoms", "disable phantom (external) nodes for imported/required library calls") - .option("--call-graph-provider ", "call-graph backend: tsc (default) | jelly | both", "tsc") + .option( + "--call-graph-provider ", + "call-graph backend: union (default, tsc ∪ jelly) | tsc | jelly | both (deprecated alias of union)", + "union", + ) + .option("--tsc-only", "use the tsc resolver only — opt out of Jelly edges (overrides --call-graph-provider)") .option("-c, --cache-dir ", "cache/intermediate directory") .option("-v, --verbose", "increase verbosity (repeatable)", (_v: string, prev: number) => prev + 1, 0) .allowExcessArguments(true); @@ -62,8 +67,22 @@ export function parseArgs(argv: string[]): AnalysisOptions { if (emit !== "schema" && !o.input) program.error("required option '-i, --input ' not specified"); const targets: string[] | null = Array.isArray(o.targetFiles) && o.targetFiles.length ? o.targetFiles.map(String) : null; - const cgProvider: CallGraphProviderName = - o.callGraphProvider === "jelly" ? "jelly" : o.callGraphProvider === "both" ? "both" : "tsc"; + // --tsc-only is the forced opt-out: it wins over --call-graph-provider. Otherwise `both` is a + // deprecated alias of `union` (warn, but honor it); unknown values fall back to the union default. + let cgProvider: CallGraphProviderName; + if (o.tscOnly) { + cgProvider = "tsc"; + } else if (o.callGraphProvider === "tsc") { + cgProvider = "tsc"; + } else if (o.callGraphProvider === "jelly") { + cgProvider = "jelly"; + } else { + if (o.callGraphProvider === "both") { + // stderr only — stdout may carry compact JSON when -o is omitted. + console.error("warning: --call-graph-provider both is deprecated; it now behaves as 'union' (tsc ∪ jelly)."); + } + cgProvider = "union"; + } return { input: o.input ? path.resolve(String(o.input)) : "", diff --git a/src/core.ts b/src/core.ts index 52afd05..7c00ee9 100644 --- a/src/core.ts +++ b/src/core.ts @@ -22,7 +22,7 @@ export function analyze(opts: AnalysisOptions): TSApplication { const cached = opts.eager ? null : loadCache(cacheDir); const { project, symbol_table } = buildSymbolTable(opts, mat, cached?.symbol_table ?? null, log); - // Call graph via the selected provider (tsc resolver by default; jelly / both opt-in). + // Call graph via the selected provider (union of tsc+jelly by default; --tsc-only / jelly opt-in). const provider = selectProvider(opts.callGraphProvider); log.info(`call graph provider: ${provider.name}`); const cg = provider.build({ project, symbol_table, root: opts.input, log, phantoms: opts.phantoms }); diff --git a/src/options/options.ts b/src/options/options.ts index 4f82d51..db83fbb 100644 --- a/src/options/options.ts +++ b/src/options/options.ts @@ -1,5 +1,5 @@ export type EmitTarget = "json" | "neo4j" | "schema"; -export type CallGraphProviderName = "tsc" | "jelly" | "both"; +export type CallGraphProviderName = "union" | "tsc" | "jelly"; /** Normalized analysis options (produced by the CLI layer, consumed by core). */ export interface AnalysisOptions { @@ -28,7 +28,7 @@ export interface AnalysisOptions { noBuild: boolean; /** Emit phantom (external) nodes/edges for imported/required library call targets. Default on. */ phantoms: boolean; - /** Which call-graph backend to use: tsc resolver (default), jelly (cs-au-dk), or both (diff). */ + /** Call-graph backend: union of tsc+jelly (default), tsc resolver only (--tsc-only), or jelly. */ callGraphProvider: CallGraphProviderName; /** Where caches/intermediate state live; null ⇒ /.codeanalyzer. */ cacheDir: string | null; diff --git a/src/semantic_analysis/index.ts b/src/semantic_analysis/index.ts index b82ef02..76df475 100644 --- a/src/semantic_analysis/index.ts +++ b/src/semantic_analysis/index.ts @@ -1,5 +1,5 @@ // Call-graph construction: the tsc (ts-morph checker) resolver graph + RTA. export * from "./callGraph"; -// The provider seam (tsc | jelly | both) + the Jelly backend. +// The provider seam (union | tsc | jelly) + the Jelly backend. export * from "./provider"; export * from "./jellyProvider"; diff --git a/src/semantic_analysis/provider.ts b/src/semantic_analysis/provider.ts index 6d09cf7..bffea78 100644 --- a/src/semantic_analysis/provider.ts +++ b/src/semantic_analysis/provider.ts @@ -1,10 +1,15 @@ /** * Call-graph provider seam. The orchestrator builds the graph through a CallGraphProvider so the - * backend is swappable: `tsc` (the always-on ts-morph resolver) or `jelly` (cs-au-dk flow-based), - * with a `both` mode that runs each and logs an edge-set diff for comparison. + * backend is swappable: + * • `union` (default) — run tsc + jelly and emit the MERGED edge/node set (tsc ∪ jelly), tagged + * by `provenance` so consumers can still tell the two apart. + * • `tsc` — the always-on ts-morph resolver only (the explicit `--tsc-only` opt-out). + * • `jelly` — the cs-au-dk flow-based analyzer only. + * `both` is a deprecated alias of `union`: it used to run each and log a diff while emitting tsc + * only, which silently discarded every jelly edge and external symbol (see issue #11). */ import type { Project } from "ts-morph"; -import type { TSModule } from "../schema"; +import type { TSExternalSymbol, TSModule } from "../schema"; import type { Logger } from "../utils"; import { buildCallGraph, type CallGraphResult } from "./callGraph"; import { jellyProvider } from "./jellyProvider"; @@ -23,52 +28,88 @@ export interface CallGraphProvider { build(ctx: CallGraphContext): CallGraphResult; } -/** The default, always-available backend — wraps the existing tsc resolver with zero behavior change. */ +/** The always-available backend — wraps the existing tsc resolver with zero behavior change. */ export const tscProvider: CallGraphProvider = { name: "tsc", build: (ctx) => buildCallGraph(ctx.project, ctx.symbol_table, ctx.root, ctx.log, ctx.phantoms), }; /** - * Run tsc (authoritative) + jelly (experimental), log how their edge sets differ, and return the - * tsc result unchanged. This is the safe "compare before promoting" mode: the emitted graph is - * still the trusted tsc one; jelly only feeds the diagnostic. + * Merge two call-graph results into their union. Pure (no I/O) so it can be unit-tested directly. + * + * Edges are keyed by `(source, target)`. A duplicate edge sums its weight, unions its `provenance` + * (so an edge found by both providers carries `["tsc", "jelly"]`), and merges its tags (base wins + * on conflict — the tsc edge is the authoritative one for the shared key). External symbols union + * by signature, base winning on conflict. `a` is treated as the base (tsc), `b` as the overlay + * (jelly). */ -export const bothProvider: CallGraphProvider = { - name: "both", +export function mergeCallGraphs(a: CallGraphResult, b: CallGraphResult): CallGraphResult { + const byKey = new Map(); + const key = (e: { source: string; target: string }): string => `${e.source} ${e.target}`; + + for (const e of a.edges) byKey.set(key(e), { ...e, provenance: [...e.provenance], tags: { ...e.tags } }); + for (const e of b.edges) { + const ex = byKey.get(key(e)); + if (!ex) { + byKey.set(key(e), { ...e, provenance: [...e.provenance], tags: { ...e.tags } }); + continue; + } + ex.weight += e.weight; + for (const p of e.provenance) if (!ex.provenance.includes(p)) ex.provenance.push(p); + for (const [k, v] of Object.entries(e.tags)) if (!(k in ex.tags)) ex.tags[k] = v; + } + + const external_symbols: Record = { ...b.external_symbols, ...a.external_symbols }; + return { edges: [...byKey.values()], external_symbols }; +} + +/** Count how the two edge sets overlap — preserves the old `both`-mode diagnostic. */ +function diffSummary(tsc: CallGraphResult, jelly: CallGraphResult): string { + const key = (e: { source: string; target: string }): string => `${e.source} ${e.target}`; + const tscKeys = new Set(tsc.edges.map(key)); + const jellyKeys = new Set(jelly.edges.map(key)); + let shared = 0; + for (const k of jellyKeys) if (tscKeys.has(k)) shared++; + return ( + `${shared} shared, ${tscKeys.size - shared} tsc-only, ${jellyKeys.size - shared} jelly-only ` + + `(tsc=${tscKeys.size}, jelly=${jellyKeys.size})` + ); +} + +/** + * Run tsc + jelly and emit their union. This is the default: jelly's edges and external symbols are + * PERSISTED (tagged `provenance: ["jelly"]`) instead of being discarded after a diff. If jelly + * fails, degrade to tsc only rather than failing the whole analysis. + */ +export const unionProvider: CallGraphProvider = { + name: "union", build(ctx) { const tsc = tscProvider.build(ctx); let jelly: CallGraphResult; try { jelly = jellyProvider.build(ctx); } catch (e) { - ctx.log.info(`call graph (both): jelly failed (${(e as Error).message}); returning tsc only`); + ctx.log.info(`call graph (union): jelly failed (${(e as Error).message}); emitting tsc only`); return tsc; } - diffEdges(tsc, jelly, ctx.log); - return tsc; + ctx.log.info(`call graph diff: ${diffSummary(tsc, jelly)}`); + const merged = mergeCallGraphs(tsc, jelly); + ctx.log.info( + `call graph (union): ${merged.edges.length} edges, ` + + `${Object.keys(merged.external_symbols).length} external symbols`, + ); + return merged; }, }; -function diffEdges(tsc: CallGraphResult, jelly: CallGraphResult, log: Logger): void { - const key = (e: { source: string; target: string }): string => `${e.source} ${e.target}`; - const tscKeys = new Set(tsc.edges.map(key)); - const jellyKeys = new Set(jelly.edges.map(key)); - let shared = 0; - for (const k of jellyKeys) if (tscKeys.has(k)) shared++; - log.info( - `call graph diff: ${shared} shared, ${tscKeys.size - shared} tsc-only, ${jellyKeys.size - shared} jelly-only ` + - `(tsc=${tscKeys.size}, jelly=${jellyKeys.size})`, - ); -} - export function selectProvider(name: string): CallGraphProvider { switch (name) { + case "tsc": + return tscProvider; case "jelly": return jellyProvider; - case "both": - return bothProvider; default: - return tscProvider; + // "union" (the default) and the deprecated "both" alias both land here. + return unionProvider; } } diff --git a/test/union-provider.test.ts b/test/union-provider.test.ts new file mode 100644 index 0000000..a624160 --- /dev/null +++ b/test/union-provider.test.ts @@ -0,0 +1,61 @@ +/** + * Unit tests for the union merge (issue #11): tsc + jelly edges and external symbols must be + * combined — not discarded — with provenance preserved so consumers can still tell them apart. + */ +import { describe, expect, test } from "bun:test"; +import type { CallGraphResult } from "../src/semantic_analysis"; +import { mergeCallGraphs } from "../src/semantic_analysis"; +import { CALL_DEP, type TSCallEdge } from "../src/schema"; + +const edge = (source: string, target: string, provenance: string[], extra: Partial = {}): TSCallEdge => ({ + source, + target, + type: CALL_DEP, + weight: 1, + provenance, + tags: {}, + ...extra, +}); + +const result = (edges: TSCallEdge[], external: CallGraphResult["external_symbols"] = {}): CallGraphResult => ({ + edges, + external_symbols: external, +}); + +describe("mergeCallGraphs", () => { + test("keeps jelly-only edges (the bug: they used to be dropped)", () => { + const tsc = result([edge("a", "b", ["tsc"])]); + const jelly = result([edge("c", "d", ["jelly"])]); + const merged = mergeCallGraphs(tsc, jelly); + const keys = merged.edges.map((e) => `${e.source}->${e.target}`).sort(); + expect(keys).toEqual(["a->b", "c->d"]); + }); + + test("an edge found by both carries both provenances and summed weight", () => { + const tsc = result([edge("a", "b", ["tsc"], { weight: 2 })]); + const jelly = result([edge("a", "b", ["jelly"], { weight: 3 })]); + const merged = mergeCallGraphs(tsc, jelly); + expect(merged.edges).toHaveLength(1); + expect(merged.edges[0].provenance.sort()).toEqual(["jelly", "tsc"]); + expect(merged.edges[0].weight).toBe(5); + }); + + test("merges external symbols from both, tsc winning on conflict", () => { + const tsc = result([], { "pkg.foo": { name: "foo", module: "pkg" } }); + const jelly = result([], { + "pkg.foo": { name: "FOO-jelly", module: "pkg" }, + "pkg.bar": { name: "bar", module: "pkg" }, + }); + const merged = mergeCallGraphs(tsc, jelly); + expect(Object.keys(merged.external_symbols).sort()).toEqual(["pkg.bar", "pkg.foo"]); + expect(merged.external_symbols["pkg.foo"].name).toBe("foo"); // base (tsc) wins + }); + + test("does not mutate the input results", () => { + const tsc = result([edge("a", "b", ["tsc"])]); + const jelly = result([edge("a", "b", ["jelly"])]); + mergeCallGraphs(tsc, jelly); + expect(tsc.edges[0].provenance).toEqual(["tsc"]); + expect(tsc.edges[0].weight).toBe(1); + }); +});