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
23 changes: 14 additions & 9 deletions README.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -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

Expand All@@ -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
Expand DownExpand Up@@ -169,8 +171,11 @@ Options:
node_modules)
--no-phantoms disable phantom (external) nodes for
imported/required library calls
--call-graph-provider <name> call-graph backend: tsc (default) | jelly |
both (default: "tsc")
--call-graph-provider <name> 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 <dir> cache/intermediate directory
-v, --verbose increase verbosity (repeatable)
-h, --help display help for command
Expand DownExpand Up@@ -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 callgraph (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:**
Expand Down
25 changes: 22 additions & 3 deletions src/cli.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 <name>", "call-graph backend: tsc (default) | jelly | both", "tsc")
.option(
"--call-graph-provider <name>",
"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 <dir>", "cache/intermediate directory")
.option("-v, --verbose", "increase verbosity (repeatable)", (_v: string, prev: number) => prev + 1, 0)
.allowExcessArguments(true);
Expand All@@ -62,8 +67,22 @@ export function parseArgs(argv: string[]): AnalysisOptions {
if (emit !== "schema" && !o.input) program.error("required option '-i, --input <path>' 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)) : "",
Expand Down
2 changes: 1 addition & 1 deletion src/core.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -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 });
Expand Down
4 changes: 2 additions & 2 deletions src/options/options.ts
Original file line numberDiff line numberDiff line change
@@ -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 {
Expand DownExpand Up@@ -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 ⇒ <input>/.codeanalyzer. */
cacheDir: string | null;
Expand Down
2 changes: 1 addition & 1 deletion src/semantic_analysis/index.ts
Original file line numberDiff line numberDiff line change
@@ -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";
95 changes: 68 additions & 27 deletions src/semantic_analysis/provider.ts
Original file line numberDiff line numberDiff line change
@@ -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";
Expand All@@ -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<string, CallGraphResult["edges"][number]>();
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<string, TSExternalSymbol> = { ...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;
}
}
61 changes: 61 additions & 0 deletions test/union-provider.test.ts
Original file line numberDiff line numberDiff line change
@@ -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> = {}): 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);
});
});