From a109a77a2571d7626b2091000a662fb1628a6fea Mon Sep 17 00:00:00 2001 From: Rahul Krishna Date: Tue, 7 Jul 2026 00:46:27 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20skip=20the=20call-graph=20(incl.=20jelly?= =?UTF-8?q?)=20solve=20at=20-a=201=20=E2=80=94=20the=20result=20is=20disca?= =?UTF-8?q?rded?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The v2 emitter only reads external_symbols/synthesized_callables and populates call_graph at level >= 2 (homeExternals/homeSynthesized in src/schema/v2/emit.ts), so running the full provider solve — including the heavier Jelly leg — at -a 1 computed a result that was thrown away. Gate provider.build to analysisLevel >= 2; levels 3/4 already require >= 2 for callee resolution, so this is safe. --- src/core.ts | 10 +++++++++- test/schema-v2.test.ts | 38 +++++++++++++++++++++++++++++++++++++- 2 files changed, 46 insertions(+), 2 deletions(-) diff --git a/src/core.ts b/src/core.ts index 5b67226..177ce9d 100644 --- a/src/core.ts +++ b/src/core.ts @@ -29,9 +29,17 @@ export async function analyze(opts: AnalysisOptions): Promise { const extraction = opts.analysisLevel >= 3 ? startExtraction(project, symbol_table, mat.tsConfigFilePath, opts, log) : null; // Call graph via the selected provider (union of tsc+jelly by default; --tsc-only / jelly opt-in). + // Only worth running at level >= 2: the v2 emitter discards call_graph/external_symbols/ + // synthesized_callables at -a 1 (homeExternals/homeSynthesized in src/schema/v2/emit.ts are + // gated to `level >= 2`), so running the solve — including the heavier Jelly leg — at -a 1 + // would compute a result that's thrown away. Levels 3/4 need the provider for callee + // resolution and are always >= 2, so this gate is safe. 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 }); + const cg = + opts.analysisLevel >= 2 + ? provider.build({ project, symbol_table, root: opts.input, log, phantoms: opts.phantoms }) + : { edges: [], external_symbols: {}, synthesized_callables: {} }; const call_graph = cg.edges; const app: TSApplication = { diff --git a/test/schema-v2.test.ts b/test/schema-v2.test.ts index 6e151e3..d1ccbdf 100644 --- a/test/schema-v2.test.ts +++ b/test/schema-v2.test.ts @@ -5,7 +5,7 @@ * bindings as module `fields{}`, and `body` `call` nodes at L1 — while remaining a superset of * every v1 symbol-table fact. */ -import { describe, expect, test } from "bun:test"; +import { describe, expect, spyOn, test } from "bun:test"; import * as fs from "node:fs"; import * as os from "node:os"; import * as path from "node:path"; @@ -15,6 +15,7 @@ import type { AnalysisOptions } from "../src/options"; import type { GraphSelector, TSApplication } from "../src/schema"; import { type V2Application, type V2Callable, type V2Module, type V2Type, toV2Detailed } from "../src/schema/v2"; import { type GraphRows, project } from "../src/build/neo4j"; +import { tscProvider } from "../src/semantic_analysis"; const FIXTURE = path.resolve(import.meta.dir, "fixtures/sample-app"); @@ -188,6 +189,41 @@ describe("schema v2 — L1 superset", () => { }); }); +// ---- L1: the call-graph solve is skipped entirely (issue #31) ------------------------------- +// The solve (including the heavier Jelly leg) is only useful from L2 up — the v2 emitter never +// reads call_graph/external_symbols/synthesized_callables at L1 (homeExternals/homeSynthesized +// are gated to `level >= 2` in src/schema/v2/emit.ts). This locks BOTH the output invariant +// (already true before the -a 1 gating fix) AND, via a spy, that the provider itself is skipped. +describe("schema v2 — L1 skips the call-graph solve (issue #31)", () => { + test("provider.build is not invoked and no call-graph/external/synth data appears at -a 1", async () => { + const spy = spyOn(tscProvider, "build"); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-v2-l1-guard-")); + try { + const v1L1 = await analyze({ ...options(), analysisLevel: 1, callGraphProvider: "tsc", cacheDir }); + expect(spy).not.toHaveBeenCalled(); + expect(v1L1.call_graph).toEqual([]); + expect(Object.keys(v1L1.external_symbols)).toEqual([]); + expect(Object.keys(v1L1.synthesized_callables)).toEqual([]); + } finally { + spy.mockRestore(); + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); + + test("provider.build IS invoked at -a 2, and produces edges (baseline: L2 unaffected)", async () => { + const spy = spyOn(tscProvider, "build"); + const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-v2-l1-guard-l2-")); + try { + const v1L2guard = await analyze({ ...options(), analysisLevel: 2, callGraphProvider: "tsc", cacheDir }); + expect(spy).toHaveBeenCalledTimes(1); + expect(v1L2guard.call_graph.length).toBeGreaterThan(0); + } finally { + spy.mockRestore(); + fs.rmSync(cacheDir, { recursive: true, force: true }); + } + }); +}); + // ---- L2: call graph ------------------------------------------------------------------------- async function runL2(): Promise { const cacheDir = fs.mkdtempSync(path.join(os.tmpdir(), "cants-v2-l2-"));