diff --git a/.github/workflows/ci-tests.yml b/.github/workflows/ci-tests.yml index 235f60183..2716d47ed 100644 --- a/.github/workflows/ci-tests.yml +++ b/.github/workflows/ci-tests.yml @@ -266,6 +266,16 @@ jobs: run: node --import tsx bench/scope-capture/measure.mjs --check working-directory: gitnexus + - name: CFG construction time / disk / memory guards (#2081 M1) + # Build-free: asserts collectFunctionCfgs output is unchanged + # (fingerprint) and that wall-time, cfgSideChannel disk bytes, AND + # retained heap all stay sub-quadratic for the straight-line / + # many-functions / branchy scenarios. Catches an O(n^2) re-regression in + # the per-function CFG builder (e.g. an extendBlock concat chain) and a + # memory/disk blow-up. --expose-gc enables the retained-heap measurement. + run: node --expose-gc --import tsx bench/cfg/measure.mjs --check + working-directory: gitnexus + - name: Cross-language pipeline benchmarks (GITNEXUS_BENCH, serial) env: GITNEXUS_BENCH: '1' diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 01013c71c..b3319f172 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -204,6 +204,10 @@ Language-agnostic scope-resolution resolver. This is the resolution path for eve Orchestrator: `runScopeResolution(input, provider)` in `scope-resolution/pipeline/run.ts`. Pipeline phase: `scopeResolutionPhase` in `scope-resolution/pipeline/phase.ts` — iterates the registered `SCOPE_RESOLVERS` over the worker-serialized `ParsedFile`s. (Per-language `emitScopeCaptures` hooks may reuse a cached Tree via the orchestrator's `treeCache`, but in worker-pool runs that cache is empty — Trees can't cross MessageChannels — so they consume the pre-extracted `ParsedFile` instead; § Performance notes.) +### Optional CFG/PDG emission (`--pdg`, #2081 M1) + +On a `--pdg` run, the parse worker builds a per-function control-flow graph from the tree-sitter AST (`LanguageProvider.cfgVisitor`; TypeScript/JavaScript in M1) and serializes it onto `ParsedFile.cfgSideChannel` as plain data. Scope-resolution then emits `BasicBlock` nodes + `CFG` edges from that side-channel **inside Phase 4 of `runScopeResolution`, while the disk-backed ParsedFile store is still live** — the only window where the worker-built CFGs are loaded (the store is cleared right after the phase returns). A standalone post-`mro` phase would read an empty store, so the CFG emit deliberately lives in-phase, mirroring the `applyCaptureSideChannel` pattern. The opt-in is off by default (graph byte-identical), folded into the parse-cache key (a pdg-off warm cache is never reused on a `--pdg` run), and bounded by a per-function edge cap that logs any dropped edges. Edge *kind* (`seq`/`cond-true`/`loop-back`/…) rides in the `CFG` relationship's `reason` (CFG is a single `CodeRelation` type, not one type per kind). See `core/ingestion/cfg/`. + ### `ScopeResolver` contract Single interface a language implements to plug into the pipeline. Contract fully documented in `scope-resolution/contract/scope-resolver.ts`. diff --git a/README.md b/README.md index cec967931..aea1079ee 100644 --- a/README.md +++ b/README.md @@ -703,6 +703,8 @@ GitNexus builds a complete knowledge graph of your codebase through a multi-phas **Imports** — cross-file import resolution · **Named Bindings** — `import { X as Y }` / re-export tracking · **Exports** — public/exported symbol detection · **Heritage** — class inheritance, interfaces, mixins · **Type Annotations** — explicit type extraction for receiver resolution · **Constructor Inference** — infer receiver type from constructor calls (`self`/`this` resolution included for all languages) · **Config** — language toolchain config parsing (tsconfig, go.mod, etc.) · **Frameworks** — AST-based framework pattern detection · **Entry Points** — entry point scoring heuristics +**Control flow (CFG, opt-in `--pdg`)** — per-function control-flow graphs (`BasicBlock` nodes + `CFG` edges) feeding the PDG/taint substrate, currently **TypeScript & JavaScript** (#2081 M1); other languages planned. Off by default. + --- ## Tool Examples diff --git a/gitnexus-shared/src/scope-resolution/parsed-file.ts b/gitnexus-shared/src/scope-resolution/parsed-file.ts index 98a327ad4..01b70f206 100644 --- a/gitnexus-shared/src/scope-resolution/parsed-file.ts +++ b/gitnexus-shared/src/scope-resolution/parsed-file.ts @@ -98,4 +98,26 @@ export interface ParsedFile { * side effects — the contract default) leave this undefined. */ readonly captureSideChannel?: unknown; + + /** + * Per-function control-flow graphs for this file (#2081 M1, PDG/taint + * substrate). A DISTINCT field from {@link captureSideChannel} — different + * producer, consumer, and lifecycle: the worker builds it from the + * tree-sitter AST via `LanguageProvider.cfgVisitor` (only on a `--pdg` run), + * and scope-resolution emits BasicBlock nodes + CFG edges from it while the + * disk-backed ParsedFile store is still live (it is NOT a capture-time + * marker the resolver restores into module maps). Kept separate so a future + * change to either channel's shape invalidates independently. + * + * Shared / ingestion code treats this as opaque (`unknown`) per AGENTS.md. + * Concretely it is a `readonly FunctionCfg[]` (see + * `core/ingestion/cfg/types.ts`) — plain JSON-serializable data (no AST + * refs, no class instances) so it round-trips through the parse cache and + * the `parsedfile-store` (whose interning reviver keys on `nodeId`, which + * these blocks/edges deliberately lack). + * + * Optional: `undefined` on non-`--pdg` runs and for languages with no + * `cfgVisitor` — the default for every run today. + */ + readonly cfgSideChannel?: unknown; } diff --git a/gitnexus/bench/cfg/baselines.json b/gitnexus/bench/cfg/baselines.json new file mode 100644 index 000000000..f830cc680 --- /dev/null +++ b/gitnexus/bench/cfg/baselines.json @@ -0,0 +1,23 @@ +{ + "straight-line": { + "fingerprint": "f5524690b5b7d484573710938c5e9a28e08ef0882fea95111f01575c71f4a66a", + "scaling_budget": 1.5, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "_note": "#2081 M1: ONE function, N coalescing statements (extendBlock text accumulation). Runs at 2000->8000 (larger than the other scenarios — output is constant 4 blocks, so disk/heap can't see this path; the TIME ratio is the sole guard). Verified at this N: the array-join impl is ~1.0, a V8-rope-optimized `+=` is also ~1.0 (correctly NOT a real regression — ropes keep naive concat linear), but a genuine O(n²) accumulation (e.g. re-join-the-array-every-append) is ~3.8 — so budget 1.5 catches a true superlinear regression while passing linear concat. disk ~1.03, retained heap ~0.98. Re-baseline the fingerprint only on an intentional CFG-shape change." + }, + "many-functions": { + "fingerprint": "c167ccd83086254e2b71eca153ca4a833be14b2d2a3827ab76b49f643aad13d5", + "scaling_budget": 1.5, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "_note": "#2081 M1: N small branchy functions (collect walk + per-function build). Time ~1.0, disk ~1.01, retained heap ~1.0 (~1KB/function; ~2MB at 2000 fns)." + }, + "branchy": { + "fingerprint": "944ab56ffc70e195f74d8533a8aadf4930d37d13bcfa47cc4feff29e74ddca5c", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "_note": "#2081 M1: ONE function, N sequential ifs (block/edge growth in one CFG). Time ~1.1-1.25 (REPS=15 median; noisiest scenario), disk ~1.04, retained heap ~1.0. Time budget 1.8 absorbs noise while catching ~4.0 quadratic." + } +} diff --git a/gitnexus/bench/cfg/measure.mjs b/gitnexus/bench/cfg/measure.mjs new file mode 100644 index 000000000..115c878a8 --- /dev/null +++ b/gitnexus/bench/cfg/measure.mjs @@ -0,0 +1,288 @@ +/** + * Build-free CFG-construction measurement harness (#2081 M1). + * + * Times `collectFunctionCfgs` (the per-function CFG builder the parse worker + * runs on a `--pdg` run) on synthetic TS sources at two sizes, in three + * scenarios that each stress a distinct cost dimension: + * - `straight-line`: ONE function with N coalescing statements — stresses the + * basic-block text accumulation (the `extendBlock` path); + * - `many-functions`: N small branchy functions — stresses the collect walk + + * per-function build + the tree-sitter `namedChildren` accesses; + * - `branchy`: ONE function with N sequential `if`s — stresses block/edge + * growth within a single CFG. + * + * For each scenario it reports three scaling ratios at small→large + * (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the + * O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain): + * - TIME — wall-clock of `collectFunctionCfgs` (median of reps); + * - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg` + * run writes onto every ParsedFile shard); + * - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the + * release-delta method (heap held minus heap after dropping it). Requires + * `node --expose-gc`; without it the heap metric is null and its gate skips. + * It also computes an order-independent sha256 fingerprint over the emitted + * blocks/edges of a fixed-size source — the correctness gate that a structural + * speedup must leave behavior-identical. + * + * Build-free: imports the `.ts` hotpaths through tsx + * (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE + * per size and the tree is reused across reps so the time measurement isolates + * CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap) + * here on purpose — the bench measures the algorithm; the production default cap + * is a separate safety net (and would otherwise skip the large straight-line fn). + * + * Without args: prints one JSON object per scenario. + * With `--check`: asserts each scenario's fingerprint == its committed baseline + * (baselines.json) AND each of the time / disk / heap ratios is below its + * recorded budget; exits non-zero on any drift/regression. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import crypto from 'node:crypto'; +import { fileURLToPath } from 'node:url'; + +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts'; +import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts'; +import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const BASELINE_PATH = path.resolve(__dirname, 'baselines.json'); + +const visitor = createTypeScriptCfgVisitor(); +const parser = new Parser(); +parser.setLanguage(TypeScript.typescript); +// Large synthetic sources exceed tree-sitter's default read buffer; size it +// from the content exactly as the parse worker does (getTreeSitterBufferSize). +const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) }); + +// ---- synthetic generators (one cost dimension each) ---- + +const SCENARIOS = [ + { + name: 'straight-line', + // One function, N coalescing simple statements → all fold into one basic + // block whose text is accumulated statement-by-statement (extendBlock). + // Uses LARGER sizes than the other scenarios: this scenario's only cost + // dimension is text accumulation (output size is constant — 4 blocks at any + // N — so the disk/heap ratios can't see it), so the TIME ratio is the sole + // guard against an extendBlock O(n²)-concat re-regression. At small N a + // quadratic is masked by V8 cons-strings + the linear tree-walk and slips + // under the budget; these larger sizes make a real quadratic separate + // cleanly (verified: a `+=` regression here exceeds the budget, the + // array-join impl stays ~1). + small: 2000, + large: 8000, + gen: (n) => { + let s = 'function f() {\n'; + for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`; + return s + ' return v0;\n}\n'; + }, + }, + { + name: 'many-functions', + // N independent small functions with a branch + return → stresses the + // tree walk in collectFunctionCfgs and the per-function build. + gen: (n) => { + let s = ''; + for (let i = 0; i < n; i++) { + s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`; + } + return s; + }, + }, + { + name: 'branchy', + // One function, N sequential `if`s → N condition blocks + 2N+ edges in a + // single CFG; stresses block/edge growth and namedChildren on the body. + gen: (n) => { + let s = 'function f(x: number) {\n'; + for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`; + return s + '}\n'; + }, + }, +]; + +const SMALL = 500; +const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4 +const REPS = 15; // median over more reps → stabler time signal at small absolute ms +const FP_SIZE = 15; // fixed size for the behavior fingerprint +const NO_CAP = 0; // measure the algorithm, not the production safety cap + +// ---- timing ---- + +function median(xs) { + const s = [...xs].sort((a, b) => a - b); + const m = Math.floor(s.length / 2); + return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2; +} + +function measureCollect(src, file, reps) { + const root = parse(src).rootNode; // parse ONCE; reuse across reps + collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted) + const samples = []; + let out; + for (let i = 0; i < reps; i++) { + const start = process.hrtime.bigint(); + out = collectFunctionCfgs(root, visitor, file, NO_CAP); + samples.push(Number(process.hrtime.bigint() - start) / 1e6); + } + return { + ms: median(samples), + blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0), + // DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly + // what a --pdg run writes onto every ParsedFile shard in the durable store + // + parse cache (the field is plain JSON, so this is the on-disk delta). + // Should scale linearly with source covered; a super-linear ratio means the + // CFG duplicates text and bloats warm-cache shards at scale. + diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'), + }; +} + +// ---- memory growth: retained heap of the cfgSideChannel payload ---- + +// Needs `node --expose-gc` to force collection for a clean delta; without it the +// heap metric is reported as null and its --check gate is skipped (so a local +// run without the flag still works). +const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null; + +function retainedHeapBytes(src, file) { + if (!GC) return null; + // Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them, + // GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel + // payload retains (the extra RAM a --pdg run carries per file until the shard + // is flushed) — robust to pre-existing garbage, which is constant across both + // measurements. The parse tree is a temporary (its native memory isn't on the + // JS heap); block text strings are fresh copies, so they count here. + let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs; + GC(); + const withCfgs = process.memoryUsage().heapUsed; + if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs + cfgs = null; + GC(); + const withoutCfgs = process.memoryUsage().heapUsed; + return Math.max(0, withCfgs - withoutCfgs); +} + +// ---- correctness fingerprint (order-independent over blocks + edges) ---- + +function canonicalizeCfg(cfg) { + const blocks = cfg.blocks + .map((b) => `B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}`) + .sort(); + const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort(); + return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${blocks.join('\n')}\n${edges.join('\n')}`; +} + +function fingerprint(scenario) { + const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP); + const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n'); + return { + fingerprint: crypto.createHash('sha256').update(canon).digest('hex'), + fp_cfgs: out.cfgs.length, + fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0), + fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0), + }; +} + +function measureScenario(scenario) { + // Per-scenario sizes (straight-line needs larger N to separate a concat + // quadratic from noise — see its comment); the rest default to the globals. + const nSmall = scenario.small ?? SMALL; + const nLarge = scenario.large ?? LARGE; + const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS); + const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS); + const sizeRatio = nLarge / nSmall; + const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0; + const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0; + + // Memory growth (only when --expose-gc gave us a forced GC). + const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`); + const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`); + const heapRatio = + heapSmall !== null && heapLarge !== null && heapSmall > 0 + ? heapLarge / heapSmall / sizeRatio + : null; + + return { + scenario: scenario.name, + elapsed_ms_small: Number(small.ms.toFixed(3)), + elapsed_ms_large: Number(large.ms.toFixed(3)), + scaling_ratio: Number(scalingRatio.toFixed(3)), + disk_bytes_small: small.diskBytes, + disk_bytes_large: large.diskBytes, + disk_bytes_ratio: Number(diskRatio.toFixed(3)), + heap_bytes_small: heapSmall, + heap_bytes_large: heapLarge, + heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)), + blocks_small: small.blockCount, + blocks_large: large.blockCount, + ...fingerprint(scenario), + }; +} + +// ---- run ---- + +const CHECK = process.argv.includes('--check'); + +// The retained-heap budget is a primary regression detector, but it can only be +// measured with a forced GC. Rather than let `--check` silently PASS with the +// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly. +if (CHECK && !GC) { + process.stderr.write( + '[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' + + 'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n', + ); + process.exit(1); +} + +const results = SCENARIOS.map(measureScenario); + +if (!CHECK) { + for (const r of results) process.stdout.write(JSON.stringify(r) + '\n'); +} else { + const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8')); + const failures = []; + for (const r of results) { + const base = baselines[r.scenario]; + if (base === undefined) { + failures.push(`${r.scenario}: no baseline recorded`); + continue; + } + if (r.fingerprint !== base.fingerprint) { + failures.push( + `${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`, + ); + } + if (r.scaling_ratio >= base.scaling_budget) { + failures.push( + `${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` + + `(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`, + ); + } + if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) { + failures.push( + `${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` + + `${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`, + ); + } + // Heap gate only when measured (--expose-gc present) AND a budget exists. + if ( + base.heap_budget !== undefined && + r.heap_ratio !== null && + r.heap_ratio >= base.heap_budget + ) { + failures.push( + `${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` + + `(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`, + ); + } + process.stdout.write(JSON.stringify(r) + '\n'); + } + if (failures.length > 0) { + for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`); + process.exit(1); + } + process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`); +} diff --git a/gitnexus/src/cli/analyze-config.ts b/gitnexus/src/cli/analyze-config.ts index 31df69cd3..63a328845 100644 --- a/gitnexus/src/cli/analyze-config.ts +++ b/gitnexus/src/cli/analyze-config.ts @@ -85,6 +85,7 @@ const KEY_SPECS: Record = { skipContextFiles: { target: 'skipAgentsMd', kind: 'boolean' }, skipAiContext: { target: 'skipAgentsMd', kind: 'boolean' }, skipSkills: { target: 'skipSkills', kind: 'boolean' }, + pdg: { target: 'pdg', kind: 'boolean' }, indexOnly: { target: 'indexOnly', kind: 'boolean' }, stats: { target: 'stats', kind: 'boolean' }, noStats: { target: 'stats', kind: 'boolean-negate' }, diff --git a/gitnexus/src/cli/analyze.ts b/gitnexus/src/cli/analyze.ts index e44778785..e54601bab 100644 --- a/gitnexus/src/cli/analyze.ts +++ b/gitnexus/src/cli/analyze.ts @@ -599,6 +599,12 @@ export interface AnalyzeOptions { verbose?: boolean; /** Skip AGENTS.md and CLAUDE.md gitnexus block updates. */ skipAgentsMd?: boolean; + /** + * Build the control-flow-graph / PDG substrate (#2081 M1). Opt-in; off by + * default. Threaded to both the worker (CFG build) and scope-resolution + * (BasicBlock/CFG emit). + */ + pdg?: boolean; /** * Stats inclusion in AGENTS.md and CLAUDE.md. * @@ -1122,6 +1128,8 @@ const analyzeCommandImpl = async ( skipGit: options.skipGit, skipAgentsMd, skipSkills, + // CFG/PDG substrate opt-in (#2081 M1) — threaded to both sinks downstream. + pdg: options.pdg === true, // Resolved default branch (CLI > .gitnexusrc > auto-detect > "main") // threaded into the generated regression-compare example (#243). defaultBranch: resolvedDefaultBranch, diff --git a/gitnexus/src/cli/index.ts b/gitnexus/src/cli/index.ts index 11c10e1ad..ffe6a6483 100644 --- a/gitnexus/src/cli/index.ts +++ b/gitnexus/src/cli/index.ts @@ -52,6 +52,11 @@ program '(no-op when --index-only is also set).', ) .option('--skip-agents-md', 'Skip updating the gitnexus section in AGENTS.md and CLAUDE.md') + .option( + '--pdg', + 'Build the control-flow-graph / PDG substrate (BasicBlock nodes + CFG edges) ' + + 'for supported languages. Opt-in; off by default. (#2081 M1)', + ) .option( '--default-branch ', 'Default branch used in the generated regression-compare example (base_ref). ' + diff --git a/gitnexus/src/core/ingestion/cfg/cfg-builder.ts b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts new file mode 100644 index 000000000..976b46824 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts @@ -0,0 +1,132 @@ +/** + * CfgBuilder (issue #2081, M1) — the language-agnostic accumulator. + * + * A per-language `CfgVisitor` drives this: it creates blocks as it walks + * statements, wires edges (including back-edges and break/continue/return/throw + * targets resolved via {@link ControlFlowContext}), and calls {@link finish} to + * produce the serializable {@link FunctionCfg}. The builder owns the synthetic + * ENTRY (index 0) and EXIT blocks and de-duplicates identical edges so repeated + * `connect` calls (common when wiring a set of dangling exits) stay idempotent. + * + * It has no knowledge of any AST — it is exercised directly in unit tests with + * hand-built block sequences, which is how the classic CFG hazards are pinned + * before the tree-sitter visitor (U2) drives it. + */ +import type { BasicBlockData, CfgEdgeData, CfgEdgeKind, FunctionCfg } from './types.js'; + +interface MutableBlock { + startLine: number; + endLine: number; + /** + * Block source accumulated as fragments, joined once in {@link finish}. A + * coalescing straight-line run appends one fragment per statement; storing + * them as an array and joining at the end keeps that O(n) instead of the + * O(n²) of repeatedly concatenating onto a growing string (a long generated + * init function is the worst case — see bench/cfg). + */ + textParts: string[]; + kind: BasicBlockData['kind']; +} + +export class CfgBuilder { + private readonly blocks: MutableBlock[] = []; + private readonly edges: CfgEdgeData[] = []; + private readonly edgeKeys = new Set(); + readonly entryIndex: number; + readonly exitIndex: number; + + constructor( + private readonly filePath: string, + private readonly functionStartLine: number, + private readonly functionEndLine: number, + /** Start column of the owning function — disambiguates same-line functions + * in the BasicBlock ids (see {@link FunctionCfg.functionStartColumn}). + * Defaults to 0 for hand-built test CFGs that don't model columns. */ + private readonly functionStartColumn: number = 0, + ) { + this.entryIndex = this.newBlock(functionStartLine, functionStartLine, '', 'entry'); + this.exitIndex = this.newBlock(functionEndLine, functionEndLine, '', 'exit'); + } + + /** Create a block and return its index. */ + newBlock( + startLine: number, + endLine: number, + text: string, + kind: BasicBlockData['kind'] = 'normal', + ): number { + this.blocks.push({ startLine, endLine, textParts: text ? [text] : [], kind }); + return this.blocks.length - 1; + } + + /** Add a single edge (idempotent on from+to+kind). */ + edge(from: number, to: number, kind: CfgEdgeKind): void { + const key = `${from}->${to}:${kind}`; + if (this.edgeKeys.has(key)) return; + this.edgeKeys.add(key); + this.edges.push({ from, to, kind }); + } + + /** Wire a set of dangling exits to a single target block with one kind. */ + connect(exits: readonly number[], to: number, kind: CfgEdgeKind = 'seq'): void { + for (const from of exits) this.edge(from, to, kind); + } + + /** Extend a block's end line as more statements accrue to it. */ + extendBlock(index: number, endLine: number, appendText?: string): void { + const b = this.blocks[index]; + if (!b) return; + if (endLine > b.endLine) b.endLine = endLine; + if (appendText) b.textParts.push(appendText); + } + + get blockCount(): number { + return this.blocks.length; + } + + /** Produce the serializable CFG. Caller is responsible for having wired the + * function's dangling exits to {@link exitIndex} before calling. */ + finish(): FunctionCfg { + return { + filePath: this.filePath, + functionStartLine: this.functionStartLine, + functionEndLine: this.functionEndLine, + functionStartColumn: this.functionStartColumn, + entryIndex: this.entryIndex, + exitIndex: this.exitIndex, + blocks: this.blocks.map((b, index) => ({ + index, + startLine: b.startLine, + endLine: b.endLine, + text: b.textParts.join('\n'), + kind: b.kind, + })), + edges: [...this.edges], + }; + } +} + +/** + * Block indices reachable from `entryIndex` by following edges. Backs the + * reachability property tests (R9) over hand-built and visitor-produced CFGs. + */ +export const reachableBlocks = (cfg: FunctionCfg): Set => { + const adj = new Map(); + for (const e of cfg.edges) { + const list = adj.get(e.from); + if (list) list.push(e.to); + else adj.set(e.from, [e.to]); + } + const seen = new Set([cfg.entryIndex]); + const stack = [cfg.entryIndex]; + while (stack.length) { + const n = stack.pop() as number; + for (const next of adj.get(n) ?? []) { + if (!seen.has(next)) { + seen.add(next); + stack.push(next); + } + } + } + return seen; +}; diff --git a/gitnexus/src/core/ingestion/cfg/collect.ts b/gitnexus/src/core/ingestion/cfg/collect.ts new file mode 100644 index 000000000..890987f7d --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/collect.ts @@ -0,0 +1,63 @@ +/** + * collectFunctionCfgs (issue #2081, M1). + * + * Walks a parsed file's tree-sitter tree and builds one {@link FunctionCfg} per + * CFG-bearing function via the language's {@link CfgVisitor}. Runs IN THE PARSE + * WORKER (where the AST lives — KTD1/KTD7); the result rides on + * `ParsedFile.cfgSideChannel` across the worker→main boundary. + * + * Nested functions are enumerated independently — each gets its own CFG, and + * appears as an opaque straight-line block in its enclosing function's CFG (the + * visitor does not descend into nested function bodies). `maxFunctionLines` + * bounds per-function cost: a function whose source span exceeds the cap is + * skipped (and counted) rather than walked, so a pathological mega-function + * cannot blow up worker time/memory. A cap of `0` means no limit. + */ +import type { SyntaxNode } from '../utils/ast-helpers.js'; +import type { CfgVisitor, FunctionCfg } from './types.js'; + +/** + * Default per-function source-line cap used by the worker when the `--pdg` run + * does not specify `pdgMaxFunctionLines`. A function longer than this (almost + * always minified/generated code) is skipped rather than walked — its CFG is + * both expensive and low-value. Overridable via `PipelineOptions.pdgMaxFunctionLines`. + */ +export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000; + +export interface CollectedCfgs { + readonly cfgs: readonly FunctionCfg[]; + /** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */ + readonly skipped: number; +} + +export function collectFunctionCfgs( + root: SyntaxNode, + visitor: CfgVisitor, + filePath: string, + maxFunctionLines = 0, +): CollectedCfgs { + const cfgs: FunctionCfg[] = []; + let skipped = 0; + const stack: SyntaxNode[] = [root]; + + while (stack.length) { + const node = stack.pop() as SyntaxNode; + if (visitor.isFunction(node)) { + const lines = node.endPosition.row - node.startPosition.row + 1; + if (maxFunctionLines > 0 && lines > maxFunctionLines) { + skipped++; + } else { + const cfg = visitor.buildFunctionCfg(node, filePath); + if (cfg) cfgs.push(cfg); + } + } + // Descend regardless (a skipped mega-function may still contain small + // nested functions that are worth a CFG of their own). + for (let i = node.namedChildCount - 1; i >= 0; i--) { + const child = node.namedChild(i); + if (child) stack.push(child); + } + } + + return { cfgs, skipped }; +} diff --git a/gitnexus/src/core/ingestion/cfg/control-flow-context.ts b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts new file mode 100644 index 000000000..38c7bcbb8 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts @@ -0,0 +1,70 @@ +/** + * ControlFlowContext (issue #2081, M1). + * + * Resolves the targets of `break`/`continue` (plain and labeled) as the visitor + * descends through loops and switches. Loops and switches push a target frame + * on entry and pop it on exit; a labeled statement attaches its label to the + * frame of the construct it labels, so `break outer` / `continue outer` resolve + * against the right enclosing loop/switch rather than the nearest one. + */ + +interface LoopFrame { + readonly kind: 'loop'; + /** Block a `continue` jumps to (the loop header / update). */ + readonly continueTo: number; + /** Block a `break` jumps to (the loop exit / join). */ + readonly breakTo: number; + readonly label?: string; +} + +interface SwitchFrame { + readonly kind: 'switch'; + /** Block a `break` jumps to (after the switch). `continue` is invalid here. */ + readonly breakTo: number; + readonly label?: string; +} + +type Frame = LoopFrame | SwitchFrame; + +export class ControlFlowContext { + private readonly stack: Frame[] = []; + + pushLoop(continueTo: number, breakTo: number, label?: string): void { + this.stack.push({ kind: 'loop', continueTo, breakTo, label }); + } + + pushSwitch(breakTo: number, label?: string): void { + this.stack.push({ kind: 'switch', breakTo, label }); + } + + pop(): void { + this.stack.pop(); + } + + /** + * Target block for a `break`. With a label, the nearest enclosing frame + * carrying that label (loop or switch); without, the nearest frame of any + * kind. Returns `undefined` if there is no valid target (malformed input). + */ + breakTarget(label?: string): number | undefined { + for (let i = this.stack.length - 1; i >= 0; i--) { + const f = this.stack[i]; + if (label === undefined || f.label === label) return f.breakTo; + } + return undefined; + } + + /** + * Target block for a `continue`. With a label, the nearest enclosing **loop** + * carrying that label; without, the nearest loop (switches are skipped — you + * cannot `continue` a switch). Returns `undefined` if there is no valid loop. + */ + continueTarget(label?: string): number | undefined { + for (let i = this.stack.length - 1; i >= 0; i--) { + const f = this.stack[i]; + if (f.kind !== 'loop') continue; + if (label === undefined || f.label === label) return f.continueTo; + } + return undefined; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts new file mode 100644 index 000000000..6531b723e --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -0,0 +1,147 @@ +/** + * cfg/emit.ts (issue #2081, M1) — serialized side-channel → graph. + * + * Pure helper: given a file's per-function CFGs (off `ParsedFile.cfgSideChannel`, + * produced by the worker in U3), emit one persisted `BasicBlock` node per block + * and one `CFG` edge per edge into the {@link KnowledgeGraph}. Invoked from + * scope-resolution (run.ts Phase 4) while the disk-backed ParsedFile store is + * still live — the only window where the worker-built CFGs are loaded (KTD1/ + * KTD5). Default (`--pdg` off) runs never call this, so the emitted graph stays + * byte-identical to a pre-#2081 run. + * + * BasicBlock id: `BasicBlock::::` + * (KTD3). The function start line+column segments disambiguate blocks across + * multiple functions in one file — including same-line functions — since each + * function's block indices restart at 0; blocks carry no `name` (the + * BasicBlock table has no such column). The edge KIND + * (`seq`/`cond-true`/…) rides in the relationship `reason` — CFG edges are + * values of the single `CodeRelation` table's `type` column (`'CFG'`), so the + * kind cannot be its own edge type and is queried via `reason`. + */ +import type { KnowledgeGraph } from '../../graph/types.js'; +import { generateId } from '../../../lib/utils.js'; +import type { FunctionCfg } from './types.js'; + +/** + * Default per-function CFG edge cap. A pathological generated function could + * otherwise emit an unbounded edge set; the cap bounds graph growth and is + * overridable via `--pdg` options. `0` (in options) means no cap (unlimited + * — see the `cap` mapping in {@link emitFileCfgs}); `undefined` means this + * default. + */ +export const DEFAULT_MAX_CFG_EDGES_PER_FUNCTION = 5000; + +export interface CfgEmitResult { + blocks: number; + edges: number; + /** Edges dropped because a function's edge count exceeded the cap. */ + droppedEdges: number; + /** Number of functions that hit the cap. */ + cappedFunctions: number; +} + +const basicBlockId = ( + filePath: string, + functionStartLine: number, + functionStartColumn: number, + blockIndex: number, +): string => `BasicBlock:${filePath}:${functionStartLine}:${functionStartColumn}:${blockIndex}`; + +/** + * Whether an untrusted `cfgSideChannel` element is safe to feed to + * {@link emitFileCfgs}. Deliberately NOT full FunctionCfg validation — it + * checks exactly the fields whose corruption is SILENT given emit's + * mechanics: {@link basicBlockId} string-templates every id-anchor value + * (filePath, function start line/column, block index, edge endpoints) and + * the graph's addNode/addRelationship are no-throw Map inserts. Unchecked, + * a missing anchor field cross-wires same-`undefined`-id blocks across + * functions (addNode is first-writer-wins), and an edge endpoint that + * matches no block index becomes a dangling `BasicBlock:…:` edge that + * detonates much later at DB bulk-load instead of throwing here — so + * endpoints are checked for MEMBERSHIP in the block-index set, not just + * integer-ness. Lives in this module so the guard evolves with the id + * templating it defends (#2099 F4; M2 fields that join the id path must + * join this check). + */ +export const isEmitSafeCfg = (cfg: FunctionCfg | undefined | null): cfg is FunctionCfg => { + if ( + typeof cfg?.filePath !== 'string' || + !Number.isInteger(cfg.functionStartLine) || + !Number.isInteger(cfg.functionStartColumn) || + !Array.isArray(cfg.blocks) || + !Array.isArray(cfg.edges) + ) { + return false; + } + const blockIndices = new Set(); + for (const b of cfg.blocks) { + if (!Number.isInteger(b?.index)) return false; + blockIndices.add(b.index); + } + return cfg.edges.every((e) => blockIndices.has(e?.from) && blockIndices.has(e?.to)); +}; + +/** + * Emit BasicBlock nodes + CFG edges for every function CFG in `cfgs`. + * + * `maxEdgesPerFunction` caps edges per function. On overflow we stop emitting + * that function's remaining edges and call `onWarn` naming the dropped count — + * no silent truncation (KTD6/R6). Block nodes are always fully emitted (their + * count is bounded by the function's statement count); only edges are capped. + */ +export function emitFileCfgs( + graph: KnowledgeGraph, + cfgs: readonly FunctionCfg[], + maxEdgesPerFunction: number = DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, + onWarn?: (message: string) => void, +): CfgEmitResult { + const result: CfgEmitResult = { blocks: 0, edges: 0, droppedEdges: 0, cappedFunctions: 0 }; + const cap = maxEdgesPerFunction > 0 ? maxEdgesPerFunction : Infinity; + + for (const cfg of cfgs) { + const { filePath, functionStartLine, functionStartColumn } = cfg; + + for (const b of cfg.blocks) { + graph.addNode({ + id: basicBlockId(filePath, functionStartLine, functionStartColumn, b.index), + label: 'BasicBlock', + properties: { + name: '', // BasicBlock has no name column; identified by id + span + filePath, + startLine: b.startLine, + endLine: b.endLine, + text: b.text, + }, + }); + result.blocks++; + } + + let emittedForFn = 0; + for (const e of cfg.edges) { + if (emittedForFn >= cap) { + const dropped = cfg.edges.length - emittedForFn; + result.droppedEdges += dropped; + result.cappedFunctions++; + onWarn?.( + `[cfg] ${filePath}:${functionStartLine}: per-function CFG edge cap ` + + `(${maxEdgesPerFunction}) reached — dropped ${dropped} of ${cfg.edges.length} edges`, + ); + break; + } + const sourceId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.from); + const targetId = basicBlockId(filePath, functionStartLine, functionStartColumn, e.to); + graph.addRelationship({ + id: generateId('CFG', `${sourceId}->${targetId}:${e.kind}`), + type: 'CFG', + sourceId, + targetId, + confidence: 1.0, + reason: e.kind, // CfgEdgeKind (seq/cond-true/loop-back/…) — queryable + }); + result.edges++; + emittedForFn++; + } + } + + return result; +} diff --git a/gitnexus/src/core/ingestion/cfg/traversal-result.ts b/gitnexus/src/core/ingestion/cfg/traversal-result.ts new file mode 100644 index 000000000..d26500fa5 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/traversal-result.ts @@ -0,0 +1,21 @@ +/** + * TraversalResult (issue #2081, M1). + * + * Visiting a statement (or a statement sequence) returns the block its control + * flow ENTERS through, plus the set of blocks whose **normal** control flows + * out the bottom (the "dangling exits") — to be wired to the entry of whatever + * comes next. Abnormal exits (return/break/continue/throw) are wired directly + * to their targets during the walk and are NOT part of `exits`. + * + * A statement that cannot fall through (e.g. ends in `return`/`throw`, or both + * branches of an `if` return) yields an empty `exits` array. + */ +export interface TraversalResult { + /** Block index control enters this statement/sequence through. */ + readonly entry: number; + /** Block indices whose normal control falls out the bottom (may be empty). */ + readonly exits: readonly number[]; +} + +/** A sequence of statements that produced no blocks (e.g. an empty body). */ +export const emptyTraversal = (entry: number): TraversalResult => ({ entry, exits: [entry] }); diff --git a/gitnexus/src/core/ingestion/cfg/types.ts b/gitnexus/src/core/ingestion/cfg/types.ts new file mode 100644 index 000000000..0b28c6089 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/types.ts @@ -0,0 +1,82 @@ +/** + * CFG data model — plain, JSON-serializable types (issue #2081, M1). + * + * These cross the worker→main boundary and the disk-backed/durable ParsedFile + * store, so they must contain NO tree-sitter AST references, class instances, + * or anything that does not survive `JSON.stringify` → `JSON.parse`. Block and + * edge endpoints are referenced by integer index within a function's CFG. + * + * The per-language `CfgVisitor` (built in the parse worker, where the AST + * lives — see the M1 plan KTD1/KTD7) produces a `FunctionCfg` per function; the + * array of them is what rides on `ParsedFile.cfgSideChannel`. + */ + +/** A basic block: a maximal straight-line run of statements between leaders. */ +export interface BasicBlockData { + /** Block index within its function. The synthetic ENTRY is always 0. */ + readonly index: number; + readonly startLine: number; + readonly endLine: number; + /** Source snippet for the block (empty for synthetic ENTRY/EXIT). */ + readonly text: string; + readonly kind: 'entry' | 'exit' | 'normal'; +} + +/** Why one block flows to another — drives the `reason` on the emitted CFG edge. */ +export type CfgEdgeKind = + | 'seq' // straight-line fallthrough + | 'cond-true' // branch taken (if/while/for condition true) + | 'cond-false' // branch not taken / loop exit + | 'loop-back' // back-edge to a loop header + | 'break' // break → loop/switch exit + | 'continue' // continue → loop header + | 'return' // return → function EXIT + | 'throw' // throw → nearest handler / finally / EXIT + | 'switch-case' // dispatch to a case + | 'fallthrough'; // switch case → next case (no break) + +export interface CfgEdgeData { + readonly from: number; + readonly to: number; + readonly kind: CfgEdgeKind; +} + +/** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */ +export interface FunctionCfg { + readonly filePath: string; + /** Source span of the owning function — anchors the BasicBlock node ids. */ + readonly functionStartLine: number; + readonly functionEndLine: number; + /** + * Start COLUMN of the owning function. Combined with `functionStartLine` it + * disambiguates the BasicBlock node ids when two functions share a start line + * — e.g. `{ a: () => x(), b: () => y() }`, where both arrows begin on the same + * line and each restarts its block indices at 0. Without the column the ids + * collide and the graph's first-writer-wins `addNode` silently drops the + * second function's blocks and cross-wires its edges. + */ + readonly functionStartColumn: number; + readonly entryIndex: number; + readonly exitIndex: number; + readonly blocks: readonly BasicBlockData[]; + readonly edges: readonly CfgEdgeData[]; +} + +/** + * Per-language CFG strategy. Invoked **in the parse worker** for each function + * node. `TNode` is the language's AST node type (tree-sitter `SyntaxNode` for + * TS/JS) — kept generic so this module stays AST-library-agnostic. Returns + * `undefined` when the node is not a CFG-bearing function (the caller skips it). + */ +export interface CfgVisitor { + buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined; + + /** + * Whether `node` is a CFG-bearing function this visitor handles. Lets the + * worker enumerate functions (and apply the per-function line budget) by a + * cheap node-type test, instead of attempting to build a CFG for every AST + * node. `buildFunctionCfg` still re-checks, so this is purely an optimization + * + the seam the line-budget hooks into. + */ + isFunction(node: TNode): boolean; +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts new file mode 100644 index 000000000..79643b8d4 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts @@ -0,0 +1,581 @@ +/** + * TS/JS CfgVisitor (issue #2081, M1). + * + * Walks a TypeScript/JavaScript function's tree-sitter AST and drives the + * language-agnostic {@link CfgBuilder} to produce a serializable + * {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript + * reuses tree-sitter-javascript's statement nodes), so one visitor covers both. + * + * Design — a `visit_` dispatch over the statement taxonomy. The + * classic CFG hazards (R10) are handled explicitly: + * - loops allocate a dedicated **loop-exit** block so `break` has a concrete + * target before the loop's successor is known; `continue` targets the + * header/increment; the back-edge closes the loop. + * - `switch` cases fall through naturally: a case body that does not `break` + * yields non-empty `exits`, which we wire to the next case as `fallthrough`; + * a case that `break`s wires to the switch exit (via {@link ControlFlowContext}) + * and yields no fall-out. + * - `try/catch/finally` routes both normal completion AND a `throw` in the try + * through `finally` (the finally block post-dominates the try/catch); a + * `throw` with no catch propagates through finally to the enclosing handler. + * - labeled `break`/`continue` resolve against the labeled loop's frame. + * + * Known M1 limitations: + * - SOUNDNESS GAP (M2 blocker, not mere precision): a non-local jump + * (`break`/`continue`/`return`) out of a `try` that has a `finally` edges + * directly to its target rather than routing THROUGH the `finally` block + * first. A future taint/PDG pass will therefore MISS flow mediated by a + * `finally` on the early-exit path (e.g. a value the `finally` taints or + * sanitizes before the `return` reaches its target) — a false negative. The + * general fix duplicates `finally` per exit path; deferred past M1 and + * tracked for M2. Normal completion and `throw` DO route through `finally`. + * - A `break`/`continue` to a label on a non-loop/non-switch block, and the + * OUTER label of a doubly-labeled construct (`outer: inner: for (...)`), are + * not modeled. The jump is conservatively routed to the function EXIT (a + * sound over-approximation that keeps the graph single-exit — see visitBreak) + * rather than left as a dangling sink; only the precise labeled target is + * unmodeled. Single-labeled loops/switches resolve correctly. + * + * Block/edge accounting and reachability are pinned in + * `test/unit/cfg/cfg-builder.test.ts` (core) and + * `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard). + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { ControlFlowContext } from '../control-flow-context.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg } from '../types.js'; + +/** TS/JS node types that own a CFG-bearing function body. */ +const TS_FUNCTION_TYPES = new Set([ + 'function_declaration', + 'function_expression', + 'arrow_function', + 'method_definition', + 'generator_function_declaration', + 'generator_function', + 'async_function_declaration', + 'async_arrow_function', +]); + +/** Statement node types that break a basic block (everything else coalesces). */ +const CONTROL_FLOW_TYPES = new Set([ + 'if_statement', + 'while_statement', + 'do_statement', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'switch_statement', + 'try_statement', + 'return_statement', + 'break_statement', + 'continue_statement', + 'throw_statement', + 'labeled_statement', + 'statement_block', +]); + +const LOOP_OR_SWITCH_TYPES = new Set([ + 'while_statement', + 'do_statement', + 'for_statement', + 'for_in_statement', + 'for_of_statement', + 'switch_statement', +]); + +const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1; +const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1; + +/** A statement sequence that produced no blocks (empty body) is "transparent". */ +type SeqResult = TraversalResult | null; + +/** + * Per-function walk state. One instance is created per function so the + * {@link ControlFlowContext}, exception-handler stack, and pending label are + * scoped to that function and never leak across functions. + */ +class TsCfgWalk { + private readonly cfc = new ControlFlowContext(); + /** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */ + private readonly handlers: number[] = []; + /** Label awaiting the loop/switch it immediately precedes (labeled_statement). */ + private pendingLabel: string | undefined; + + constructor(private readonly builder: CfgBuilder) {} + + /** Statements of a block node, ignoring comments. */ + private statementsOf(block: SyntaxNode): SyntaxNode[] { + return block.namedChildren.filter((c) => c.type !== 'comment'); + } + + /** The `body` block of a node (field, or the first statement_block child). */ + private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined { + return ( + node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block') + ); + } + + /** Visit a body that may be a `statement_block` or a single statement. */ + private visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node)); + return this.visitStmt(node); + } + + /** Wire a sequence of statements, coalescing straight-line runs into blocks. */ + visitSeq(stmts: SyntaxNode[]): SeqResult { + let entry: number | undefined; + let dangling: number[] = []; + let openSimple: number | undefined; + + for (const stmt of stmts) { + if (CONTROL_FLOW_TYPES.has(stmt.type)) { + openSimple = undefined; // close any open straight-line block + const res = this.visitStmt(stmt); + if (res === null) continue; // transparent (empty nested block) + if (entry === undefined) entry = res.entry; + else this.builder.connect(dangling, res.entry, 'seq'); + dangling = [...res.exits]; + } else { + // Simple statement — coalesce into the current straight-line block. + if (openSimple === undefined) { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + if (entry === undefined) entry = idx; + else this.builder.connect(dangling, idx, 'seq'); + openSimple = idx; + dangling = [idx]; + } else { + this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text); + } + } + } + + if (entry === undefined) return null; + return { entry, exits: dangling }; + } + + /** Dispatch one statement to its handler. Non-null except for empty blocks. */ + visitStmt(stmt: SyntaxNode): SeqResult { + switch (stmt.type) { + case 'if_statement': + return this.visitIf(stmt); + case 'while_statement': + return this.visitWhile(stmt); + case 'do_statement': + return this.visitDoWhile(stmt); + case 'for_statement': + return this.visitFor(stmt); + case 'for_in_statement': + case 'for_of_statement': + return this.visitForIn(stmt); + case 'switch_statement': + return this.visitSwitch(stmt); + case 'try_statement': + return this.visitTry(stmt); + case 'return_statement': + return this.visitReturn(stmt); + case 'throw_statement': + return this.visitThrow(stmt); + case 'break_statement': + return this.visitBreak(stmt); + case 'continue_statement': + return this.visitContinue(stmt); + case 'labeled_statement': + return this.visitLabeled(stmt); + case 'statement_block': + return this.visitSeq(this.statementsOf(stmt)); + default: + return this.visitSimple(stmt); + } + } + + private visitSimple(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + return { entry: idx, exits: [idx] }; + } + + private visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + this.builder.edge(idx, this.builder.exitIndex, 'return'); + return { entry: idx, exits: [] }; + } + + private visitThrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + private visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const target = this.cfc.breakTarget(this.labelOf(stmt)); + // An unresolved target — a label this M1 visitor doesn't model (a stacked + // outer label like `outer: inner: for`, or a labeled non-loop block) — + // would otherwise leave this block with NO out-edge, stranding it and + // breaking the single-exit invariant a downstream post-dominator / PDG pass + // relies on. Conservatively route an unresolved jump to the function EXIT + // ("escapes the function"): sound over-approximation, keeps single-exit. + this.builder.edge(idx, target ?? this.builder.exitIndex, 'break'); + return { entry: idx, exits: [] }; + } + + private visitContinue(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const target = this.cfc.continueTarget(this.labelOf(stmt)); + // See visitBreak: an unresolved label routes to EXIT to preserve single-exit. + this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue'); + return { entry: idx, exits: [] }; + } + + private visitLabeled(stmt: SyntaxNode): SeqResult { + const body = + stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1]; + if (body && LOOP_OR_SWITCH_TYPES.has(body.type)) { + this.pendingLabel = this.labelOf(stmt); + const res = this.visitStmt(body); + this.pendingLabel = undefined; // clear even if the construct didn't consume it + return res; + } + // Labeled non-loop blocks (break-to-block-label) are not modeled in M1. + return this.visitBody(body); + } + + private visitIf(stmt: SyntaxNode): TraversalResult { + const cond = stmt.childForFieldName('condition') ?? stmt; + const condBlock = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text); + + const exits: number[] = []; + + const thenRes = this.visitBody(stmt.childForFieldName('consequence')); + if (thenRes) { + this.builder.edge(condBlock, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(condBlock); // empty then — true path falls through + } + + const elseNode = this.elseBodyOf(stmt); + if (elseNode) { + const elseRes = this.visitBody(elseNode); + if (elseRes) { + this.builder.edge(condBlock, elseRes.entry, 'cond-false'); + exits.push(...elseRes.exits); + } else { + exits.push(condBlock); // empty else block + } + } else { + exits.push(condBlock); // no else — false path falls through to the join + } + + return { entry: condBlock, exits: [...new Set(exits)] }; + } + + /** The else body node (unwraps an `else_clause` wrapper if present). */ + private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined { + const alt = ifStmt.childForFieldName('alternative'); + if (!alt) return undefined; + if (alt.type === 'else_clause') { + return alt.childForFieldName('body') ?? alt.namedChildren[0]; + } + return alt; + } + + private visitWhile(stmt: SyntaxNode): TraversalResult { + const label = this.takeLabel(); + const cond = stmt.childForFieldName('condition') ?? stmt; + const header = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, label); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-tests + } + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private visitDoWhile(stmt: SyntaxNode): TraversalResult { + const label = this.takeLabel(); + const cond = stmt.childForFieldName('condition') ?? stmt; + const condBlock = this.builder.newBlock(startLineOf(cond), endLineOf(cond), cond.text); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(condBlock, loopExit, label); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + const backTarget = body ? body.entry : condBlock; + if (body) this.builder.connect(body.exits, condBlock, 'seq'); + this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again + this.builder.edge(condBlock, loopExit, 'cond-false'); + return { entry: backTarget, exits: [loopExit] }; + } + + private visitFor(stmt: SyntaxNode): TraversalResult { + const label = this.takeLabel(); + const init = stmt.childForFieldName('initializer'); + const cond = stmt.childForFieldName('condition'); + const incr = stmt.childForFieldName('increment'); + + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? cond.text : 'for(;;)', + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + let incrBlock = header; + if (incr) { + incrBlock = this.builder.newBlock(startLineOf(incr), endLineOf(incr), incr.text); + this.builder.edge(incrBlock, header, 'loop-back'); + } + + this.cfc.pushLoop(incrBlock, loopExit, label); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + // With no increment clause the body's exits ARE the back-edge — carry + // the loop-back kind on them (mirroring visitWhile/visitForIn) instead + // of a phantom header→header self-loop that models a path which never + // executes the body. With an increment, the body falls through to the + // increment (`seq`) and the increment carries the loop-back (:338). + this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back'); + } else { + this.builder.edge(header, incrBlock, 'cond-true'); + // Empty body with no increment: the header genuinely re-tests itself. + if (!incr) this.builder.edge(header, header, 'loop-back'); + } + this.builder.edge(header, loopExit, 'cond-false'); + + let entry = header; + if (init) { + const initBlock = this.builder.newBlock(startLineOf(init), endLineOf(init), init.text); + this.builder.edge(initBlock, header, 'seq'); + entry = initBlock; + } + return { entry, exits: [loopExit] }; + } + + private visitForIn(stmt: SyntaxNode): TraversalResult { + const label = this.takeLabel(); + const header = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + this.forInHeaderText(stmt), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, label); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); + } + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private forInHeaderText(stmt: SyntaxNode): string { + const left = stmt.childForFieldName('left')?.text ?? ''; + const right = stmt.childForFieldName('right')?.text ?? ''; + return left || right ? `for(${left} … ${right})` : 'for(… in/of …)'; + } + + private visitSwitch(stmt: SyntaxNode): TraversalResult { + const label = this.takeLabel(); + const value = stmt.childForFieldName('value') ?? stmt; + const dispatch = this.builder.newBlock(startLineOf(stmt), endLineOf(value), value.text); + const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushSwitch(switchExit, label); + const body = stmt.childForFieldName('body'); + const cases = body + ? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default') + : []; + + const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c))); + const hasDefault = cases.some((c) => c.type === 'switch_default'); + + // entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty + // cases are transparent — they resolve to the next case, or the exit). + const entryOf: number[] = new Array(cases.length); + let after = switchExit; + for (let i = cases.length - 1; i >= 0; i--) { + entryOf[i] = caseResults[i]?.entry ?? after; + after = entryOf[i]; + } + + for (let i = 0; i < cases.length; i++) { + this.builder.edge(dispatch, entryOf[i], 'switch-case'); + } + if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path + + for (let i = 0; i < cases.length; i++) { + const res = caseResults[i]; + if (!res) continue; + const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit; + this.builder.connect(res.exits, fallTarget, 'fallthrough'); + } + + this.cfc.pop(); + return { entry: dispatch, exits: [switchExit] }; + } + + private caseStatements(caseNode: SyntaxNode): SyntaxNode[] { + const value = caseNode.childForFieldName('value'); + return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment'); + } + + private visitTry(stmt: SyntaxNode): SeqResult { + const bodyNode = stmt.childForFieldName('body'); + // Single pass over named children — tree-sitter's `namedChildren` getter + // allocates a fresh array on every access, so avoid the double `.find`. + let catchClause: SyntaxNode | undefined; + let finallyClause: SyntaxNode | undefined; + for (let i = 0; i < stmt.namedChildCount; i++) { + const c = stmt.namedChild(i); + if (c?.type === 'catch_clause') catchClause = c; + else if (c?.type === 'finally_clause') finallyClause = c; + } + + // Build finally first so its entry is known as both a normal join and a + // handler target. The finally body runs in the OUTER handler context. + const finallyRes = finallyClause + ? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause) as SyntaxNode)) + : null; + + // A throw inside catch propagates to finally (if any), else the outer handler. + let catchRes: SeqResult = null; + if (catchClause) { + if (finallyRes) this.handlers.push(finallyRes.entry); + catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause) as SyntaxNode)); + if (finallyRes) this.handlers.pop(); + if (catchRes === null) { + // Empty (or comment-only) catch body — `catch {}`. The clause still + // CATCHES: handler semantics key off the syntactic clause, not the + // traversal result. Treating it as "no catch" sent the swallowed + // exception to the outer handler/EXIT and left post-try code + // unreachable when the body always throws — a hard false-negative + // for downstream taint. Synthesize one empty block spanning the + // clause (entry == sole exit) so exception flow lands in it and + // rejoins the normal continuation. Created BEFORE the protected + // region is walked, so it never receives a spurious throw edge. + const idx = this.builder.newBlock(startLineOf(catchClause), endLineOf(catchClause), ''); + catchRes = { entry: idx, exits: [idx] }; + } + } + + // Handler for the try body: catch if present, else finally, else outer. + const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null; + this.handlers.pop(); + + // Conservative exceptional edges: ANY block in the protected region may raise + // to the handler — not just an explicit `throw`, and not just the body ENTRY. + // Edging every block created during the try-body walk keeps exception flow + // sound when the body BRANCHES: an `if` / nested-try / post-branch block whose + // interior blocks would otherwise have no path to the handler — i.e. a taint + // false-negative into `catch` for the downstream PDG analysis. The + // per-function edge cap bounds the count; explicit `throw`s add their own + // (idempotent) edge to the same handler. + if (catchClause || finallyClause) { + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, tryHandler, 'throw'); + } + } + + const exits: number[] = []; + if (finallyRes) { + // Normal completion of try AND catch both flow through finally. + if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq'); + if (catchRes) this.builder.connect(catchRes.exits, finallyRes.entry, 'seq'); + exits.push(...finallyRes.exits); + // No catch → an exception re-propagates out after finally runs. + if (!catchRes) this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw'); + } else { + if (bodyRes) exits.push(...bodyRes.exits); + if (catchRes) exits.push(...catchRes.exits); + } + + const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry; + if (entry === undefined) return null; + return { entry, exits: [...new Set(exits)] }; + } + + /** Nearest enclosing exception handler, or the function EXIT. */ + private currentHandler(): number { + return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex; + } + + /** Consume the label awaiting the loop/switch this call is building. */ + private takeLabel(): string | undefined { + const label = this.pendingLabel; + this.pendingLabel = undefined; + return label; + } + + private labelOf(stmt: SyntaxNode): string | undefined { + const id = + stmt.childForFieldName('label') ?? + stmt.namedChildren.find((c) => c.type === 'statement_identifier'); + return id?.text; + } +} + +/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */ +function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { + if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined; + const startLine = startLineOf(fnNode); + const endLine = endLineOf(fnNode); + const startColumn = fnNode.startPosition.column; + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + + const body = fnNode.childForFieldName('body'); + if (!body) return undefined; // overload signature / abstract method — no body + + if (body.type !== 'statement_block') { + // Expression-bodied arrow: `() => expr` — one block whose value is returned. + const blk = builder.newBlock(startLineOf(body), endLineOf(body), body.text); + builder.edge(builder.entryIndex, blk, 'seq'); + builder.edge(blk, builder.exitIndex, 'return'); + return builder.finish(); + } + + const walk = new TsCfgWalk(builder); + const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment')); + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + return builder.finish(); +} + +/** Whether a node is a TS/JS function this visitor builds a CFG for. */ +function isFunction(node: SyntaxNode): boolean { + return TS_FUNCTION_TYPES.has(node.type); +} + +/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */ +export function createTypeScriptCfgVisitor(): CfgVisitor { + return { buildFunctionCfg, isFunction }; +} + +export { TS_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/language-provider.ts b/gitnexus/src/core/ingestion/language-provider.ts index a896933f8..dd531d1f5 100644 --- a/gitnexus/src/core/ingestion/language-provider.ts +++ b/gitnexus/src/core/ingestion/language-provider.ts @@ -35,6 +35,7 @@ import type { MethodExtractor } from './method-types.js'; import type { VariableExtractor } from './variable-types.js'; import type { ImportResolverFn } from './import-resolvers/types.js'; import type { SyntaxNode } from './utils/ast-helpers.js'; +import type { CfgVisitor } from './cfg/types.js'; import type { NodeLabel } from 'gitnexus-shared'; import type Parser from 'tree-sitter'; import type { ExtractedDecoratorRoute } from './workers/parse-worker.js'; @@ -360,6 +361,17 @@ interface LanguageProviderConfig { */ readonly collectCaptureSideChannel?: (filePath: string) => unknown; + /** + * Per-language control-flow-graph builder (#2081 M1, PDG/taint substrate). + * Invoked IN THE PARSE WORKER (where the AST lives) for each function node, + * gated on the `--pdg` opt-in; the resulting per-function CFGs are serialized + * onto `ParsedFile.cfgSideChannel` and emitted as BasicBlock nodes + CFG + * edges during scope-resolution. `TNode` is `SyntaxNode` for the tree-sitter + * languages. Default: undefined (language has no CFG support yet — TS/JS are + * the M1 set). + */ + readonly cfgVisitor?: CfgVisitor; + /** * Interpret a raw `@import.statement` capture group into a `ParsedImport`. * The central finalize algorithm resolves `ParsedImport.targetRaw` to a diff --git a/gitnexus/src/core/ingestion/languages/typescript.ts b/gitnexus/src/core/ingestion/languages/typescript.ts index 73b33dfd8..7f41fe731 100644 --- a/gitnexus/src/core/ingestion/languages/typescript.ts +++ b/gitnexus/src/core/ingestion/languages/typescript.ts @@ -16,6 +16,7 @@ import { javascriptClassConfig, } from '../class-extractors/configs/typescript-javascript.js'; import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js'; import { typeConfig as typescriptConfig } from '../type-extractors/typescript.js'; import { tsExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; @@ -351,6 +352,8 @@ export const typescriptProvider = defineLanguage({ // canonical capture vocabulary in ./typescript/query.ts // (TYPESCRIPT_SCOPE_QUERY constant). emitScopeCaptures: emitTsScopeCaptures, + // CFG/PDG substrate (#2081 M1) — runs in the worker on a --pdg run. + cfgVisitor: createTypeScriptCfgVisitor(), interpretImport: interpretTsImport, interpretTypeBinding: interpretTsTypeBinding, bindingScopeFor: tsBindingScopeFor, @@ -412,6 +415,8 @@ export const javascriptProvider = defineLanguage({ // JSDoc type bindings) live in ./javascript/captures.ts. // See ./javascript/index.ts for the full per-module rationale. emitScopeCaptures: emitJsScopeCaptures, + // CFG/PDG substrate (#2081 M1) — TS and JS share the same grammar family. + cfgVisitor: createTypeScriptCfgVisitor(), interpretImport: interpretJsImport, interpretTypeBinding: interpretJsTypeBinding, bindingScopeFor: jsBindingScopeFor, diff --git a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts index ddc8c902e..3885df16e 100644 --- a/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts +++ b/gitnexus/src/core/ingestion/pipeline-phases/parse-impl.ts @@ -36,6 +36,7 @@ import { restoreDurableParsedFileShard, } from '../../../storage/parsedfile-store.js'; import type { ParseWorkerResult } from '../workers/parse-worker.js'; +import { DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js'; import type { WorkerExtractedData } from '../parsing-processor.js'; import { processRoutesFromExtracted, @@ -461,6 +462,10 @@ export async function runChunkedParseAndResolve( // Initialized below before the chunk loop (same deferred-init pattern // as `parsedFileStorePath`); this closure only runs from the loop. durableParsedFileStoragePath: durableParsedFileDir, + // CFG/PDG opt-in (#2081 M1) — baked into each worker's workerData so the + // worker builds + attaches cfgSideChannel. Off by default. + pdg: options?.pdg === true, + pdgMaxFunctionLines: options?.pdgMaxFunctionLines, // Fan each chunk across the whole pool (#worker-idle): without this a // chunk smaller than the 8 MB sub-batch cap became a single job on a // single worker. Honors an explicit `subBatchMaxBytes` / env override. @@ -737,7 +742,21 @@ export async function runChunkedParseAndResolve( filePath: f.path, contentHash: fileContentHash(f.content), })); - chunkHash = computeChunkHash(entries); + chunkHash = computeChunkHash( + entries, + // Only worker-visible pdg config participates in the key — + // pdgMaxEdgesPerFunction is emit-time-only and deliberately + // excluded (see PdgCacheKey in parse-cache.ts; #2099 F3). The line + // cap is RESOLVED to the worker's default before folding so an + // explicit-default run shares the default run's keys (the worker + // output is byte-identical either way). + options?.pdg === true + ? { + pdg: true, + maxFunctionLines: options?.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES, + } + : false, + ); } const cachedRaw = diff --git a/gitnexus/src/core/ingestion/pipeline.ts b/gitnexus/src/core/ingestion/pipeline.ts index 2b1709a35..32216645e 100644 --- a/gitnexus/src/core/ingestion/pipeline.ts +++ b/gitnexus/src/core/ingestion/pipeline.ts @@ -50,6 +50,29 @@ export interface PipelineOptions { * to retain those nodes under `skipGraphPhases`. */ skipGraphPhases?: boolean; + /** + * Build the control-flow-graph / PDG substrate (#2081 M1, opt-in via `--pdg`). + * Off by default: workers skip all CFG work and emit no `cfgSideChannel`, and + * scope-resolution emits no BasicBlock nodes or CFG edges — so the default + * graph is byte-identical to a pre-#2081 run. Folded into the parse-cache key + * so a pdg-off warm cache is not reused on a `--pdg` run. + */ + pdg?: boolean; + /** + * Per-function source-line cap for worker-side CFG construction. + * `undefined` ⇒ the worker applies `DEFAULT_PDG_MAX_FUNCTION_LINES`; `0` ⇒ no + * cap (unlimited). Bounds the cost of a pathological mega-function; over-cap + * functions are skipped (no CFG emitted for them). No CLI flag in M1 — + * programmatic / server analyze-worker path only. + */ + pdgMaxFunctionLines?: number; + /** + * Per-function CFG edge cap for the scope-resolution emit step. + * `undefined` ⇒ `DEFAULT_MAX_CFG_EDGES_PER_FUNCTION`; `0` ⇒ no cap (unlimited). + * Over-cap functions stop at the cap and log a structured drop warning (no + * silent truncation). No CLI flag in M1 — programmatic / server path only. + */ + pdgMaxEdgesPerFunction?: number; /** * Request parsing with the worker pool disabled. The sequential parser was * removed — the worker pool is the sole parse path — so setting this now diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts index d686bdd43..ec72c76ee 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/phase.ts @@ -350,6 +350,9 @@ export const scopeResolutionPhase: PipelinePhase = { prebuiltNodeLookup: sharedNodeLookup, preExtractedParsedFiles: preExtractedByPath, scopeIndexStorePath: parsedFileStorePath, + // CFG/PDG emission (#2081 M1) — opt-in; off ⇒ byte-identical graph. + pdg: ctx.options?.pdg === true, + pdgMaxEdgesPerFunction: ctx.options?.pdgMaxEdgesPerFunction, recordResolutionOutcome: (outcome) => { resolutionOutcomes.push(outcome); }, diff --git a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts index 88c9f5f70..d937b05b9 100644 --- a/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts +++ b/gitnexus/src/core/ingestion/scope-resolution/pipeline/run.ts @@ -34,6 +34,8 @@ import { extractParsedFile } from '../../scope-extractor-bridge.js'; import { finalizeScopeModel } from '../../finalize-orchestrator.js'; import { resolveReferenceSites, type ResolveStats } from '../../resolve-references.js'; import { buildGraphNodeLookup } from '../graph-bridge/node-lookup.js'; +import { emitFileCfgs, isEmitSafeCfg, DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from '../../cfg/emit.js'; +import type { FunctionCfg } from '../../cfg/types.js'; import { resolveDefGraphId } from '../graph-bridge/ids.js'; import { buildPopulatedMethodDispatch } from '../graph-bridge/method-dispatch.js'; import { propagateImportedReturnTypes } from '../passes/imported-return-types.js'; @@ -252,6 +254,16 @@ interface RunScopeResolutionInput { * cache miss is safe (the provider re-parses). */ readonly treeCache?: { get(filePath: string): unknown }; + /** + * CFG/PDG opt-in (#2081 M1). When true, emit BasicBlock nodes + CFG edges + * from each ParsedFile's worker-built `cfgSideChannel` during Phase-4 graph + * emission (while the disk store is still live). Default/false ⇒ no CFG + * nodes or edges and a byte-identical graph. + */ + readonly pdg?: boolean; + /** Per-function CFG edge cap. `undefined` ⇒ {@link DEFAULT_MAX_CFG_EDGES_PER_FUNCTION}; + * `0` ⇒ no cap (unlimited). */ + readonly pdgMaxEdgesPerFunction?: number; /** * Optional graph-node lookup built ONCE by the caller and shared across * every language pass. `buildGraphNodeLookup` scans the whole graph and is @@ -679,6 +691,77 @@ export function runScopeResolution( }); } + // ── CFG/PDG emission (#2081 M1, opt-in via `--pdg`) ────────────────────── + // Emit BasicBlock nodes + CFG edges from each ParsedFile's worker-built + // `cfgSideChannel`, HERE — the last point inside scope-resolution where the + // ParsedFiles are still loaded (`emitParsedFiles` carries the channel; the + // disk store is cleared right after this orchestrator returns, see phase.ts). + // A post-`mro` phase would read empty data (KTD1). Off by default ⇒ zero + // BasicBlock/CFG nodes/edges and a byte-identical graph. + if (input.pdg === true) { + let cfgBlocks = 0; + let cfgEdges = 0; + let cfgDroppedEdges = 0; + for (const pf of emitParsedFiles) { + const cfgs = pf.cfgSideChannel; + // Defensive: cfgSideChannel is opaque (`unknown`) and crosses the cache / + // durable store. A stale or wrong-shape value (e.g. a pre-SCHEMA_BUMP + // shard that slipped the version gate) must skip emission, not throw a + // TypeError mid-graph-build and abort scope-resolution for the language. + if (!Array.isArray(cfgs) || cfgs.length === 0) continue; + try { + // Per-element emit-safety filter (mirrors the parsedfile-store + // reviver's POLICY: valid elements in a mixed array still emit; junk + // is warned and skipped). isEmitSafeCfg lives in cfg/emit.ts next to + // the id templating it defends — see its doc for why anchor-field and + // endpoint-membership checks are load-bearing. Runs INSIDE the try so + // even a predicate-time throw (e.g. a hostile getter) is isolated. + const wellFormed = (cfgs as readonly (FunctionCfg | undefined | null)[]).filter( + isEmitSafeCfg, + ); + if (wellFormed.length < cfgs.length) { + logger.warn( + `[cfg] ${pf.filePath}: skipped ${cfgs.length - wellFormed.length} malformed ` + + `cfgSideChannel element(s) (bad shape, missing id-anchor fields, or edge ` + + `endpoints matching no block) — CFG for those functions omitted`, + ); + } + if (wellFormed.length === 0) continue; + const emitted = emitFileCfgs( + graph, + wellFormed, + input.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, + // Log cap-overflow drops UNCONDITIONALLY (not via input.onWarn, which is + // gated behind the semantic-model validator and silent in production) so + // the per-function edge cap never truncates the CFG silently (R6/KTD6). + (message) => logger.warn(message), + ); + cfgBlocks += emitted.blocks; + cfgEdges += emitted.edges; + cfgDroppedEdges += emitted.droppedEdges; + } catch (err) { + // Last-resort isolation, mirroring the worker-side per-file try/catch: + // a shape the predicate misses must cost this one file's CFG, not + // abort the language's whole scope-resolution pass mid-graph-build. + // NOTE a mid-emit throw can leave this file's already-inserted + // BasicBlock nodes in the graph (addNode is not transactional) — + // orphaned but inert; the predicate keeps every JSON-representable + // bad shape from reaching this path at all. + logger.warn( + `[cfg] ${pf.filePath}: CFG emission failed (${err instanceof Error ? err.message : String(err)}) — ` + + `this file's CFG is partial or absent`, + ); + } + } + if (cfgBlocks > 0) { + logger.debug( + `[scope-resolution] CFG emit (lang=${provider.language}): ` + + `${cfgBlocks} BasicBlock nodes, ${cfgEdges} CFG edges` + + (cfgDroppedEdges > 0 ? `, ${cfgDroppedEdges} edges dropped (per-function cap)` : ''), + ); + } + } + if (PROF) { const tEnd = process.hrtime.bigint(); const ns = (a: bigint, b: bigint): number => Number(b - a) / 1_000_000; diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index f5004f8fe..f29b63c16 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -130,6 +130,7 @@ import { persistDurableParsedFileShardSync, } from '../../../storage/parsedfile-store.js'; import { extractLaravelRoutes, type ExtractedRoute } from '../route-extractors/laravel.js'; +import { collectFunctionCfgs, DEFAULT_PDG_MAX_FUNCTION_LINES } from '../cfg/collect.js'; import { logger } from '../../logger.js'; export type { ExtractedRoute } from '../route-extractors/laravel.js'; @@ -155,6 +156,19 @@ const DURABLE_PARSED_FILE_STORAGE_PATH: string | undefined = ( )?.durableParsedFileStoragePath; let shardSeq = 0; +// ── PDG/CFG opt-in (#2081 M1) ─────────────────────────────────────────────── +// Read ONCE at worker init from `workerData` (the worker never sees +// PipelineOptions — config arrives via the pool factory's `workerData`, see +// KTD7 / U5). When `pdg` is set, the worker builds a per-function control-flow +// graph from the tree-sitter AST (where it lives) and serializes it onto +// `ParsedFile.cfgSideChannel`. Off ⇒ no CFG work and no field — the default for +// every run today. `pdgMaxFunctionLines` bounds per-function CFG cost +// (0/undefined ⇒ no cap; see collectFunctionCfgs). +const PDG_ENABLED: boolean = (workerData as { pdg?: boolean } | undefined)?.pdg === true; +const PDG_MAX_FUNCTION_LINES: number = + (workerData as { pdgMaxFunctionLines?: number } | undefined)?.pdgMaxFunctionLines ?? + DEFAULT_PDG_MAX_FUNCTION_LINES; + // ── Bootstrap-stage diagnostics (#1741) ──────────────────────────────────── // When GITNEXUS_WORKER_BOOTSTRAP=1 (or --verbose sets GITNEXUS_VERBOSE), each // worker reports its startup stage timings to stderr — which the pool tees @@ -1233,9 +1247,38 @@ const processFileGroup = ( // copy — scopes/defs are carried by reference) to attach the field rather // than mutate the frozen object. const sideChannel = provider.collectCaptureSideChannel?.(file.path); - result.parsedFiles.push( - sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile, - ); + let withChannels = + sideChannel !== undefined ? { ...parsedFile, captureSideChannel: sideChannel } : parsedFile; + + // CFG side-channel (#2081 M1): build the per-function control-flow graph + // here, where the tree-sitter AST is still in hand, and attach it as plain + // serializable data. Only on a --pdg run and only for languages with a + // cfgVisitor (TS/JS in M1). The same disk-store/warm-cache machinery that + // carries captureSideChannel carries this — its coherence rests on the + // SCHEMA_BUMP + the pdg-folded chunk-hash key (see parse-cache.ts). + if (PDG_ENABLED && provider.cfgVisitor) { + // Isolate the CFG build per file: a throw here (an unexpected tree-sitter + // node shape, a deep-nesting stack overflow) must NOT propagate — it + // would escape processFileGroup to the language-group catch, which treats + // any throw as "parser unavailable" and silently drops EVERY remaining + // file in the group. Skip CFG for this one file; parsing + scope + // resolution proceed unaffected (CFG is a strictly-additive opt-in). + try { + const { cfgs } = collectFunctionCfgs( + tree.rootNode, + provider.cfgVisitor, + file.path, + PDG_MAX_FUNCTION_LINES, + ); + if (cfgs.length) withChannels = { ...withChannels, cfgSideChannel: cfgs }; + } catch (err) { + const message = `CFG build failed for ${file.path}: ${err instanceof Error ? err.message : String(err)}`; + if (parentPort) parentPort.postMessage({ type: 'warning', message }); + else logger.warn(message); + } + } + + result.parsedFiles.push(withChannels); } // Build per-file type environment + constructor bindings in a single AST walk. diff --git a/gitnexus/src/core/ingestion/workers/worker-pool.ts b/gitnexus/src/core/ingestion/workers/worker-pool.ts index 038d8ed65..c46cf5a33 100644 --- a/gitnexus/src/core/ingestion/workers/worker-pool.ts +++ b/gitnexus/src/core/ingestion/workers/worker-pool.ts @@ -232,6 +232,15 @@ export interface WorkerPoolOptions { * `undefined` ⇒ no durable write. */ durableParsedFileStoragePath?: string; + /** + * CFG/PDG opt-in (#2081 M1). Baked into every spawned worker's `workerData` + * (like the store paths above); when `true`, workers build a per-function + * control-flow graph from the tree-sitter AST and attach it to + * `ParsedFile.cfgSideChannel`. `undefined`/`false` ⇒ no CFG work. + */ + pdg?: boolean; + /** Per-function source-line cap for worker-side CFG construction (0 ⇒ no cap). */ + pdgMaxFunctionLines?: number; } export class WorkerPoolDispatchError extends Error { @@ -890,9 +899,12 @@ export const createWorkerPool = ( // signature is unchanged so the zero-arg test factories keep working. const parsedFileStoreStoragePath = options?.parsedFileStoreStoragePath; const durableParsedFileStoragePath = options?.durableParsedFileStoragePath; + // CFG/PDG opt-in (#2081 M1) — carried in workerData alongside the store paths. + const pdg = options?.pdg === true; + const pdgMaxFunctionLines = options?.pdgMaxFunctionLines; const workerStoreData = - parsedFileStoreStoragePath || durableParsedFileStoragePath - ? { parsedFileStoreStoragePath, durableParsedFileStoragePath } + parsedFileStoreStoragePath || durableParsedFileStoragePath || pdg + ? { parsedFileStoreStoragePath, durableParsedFileStoragePath, pdg, pdgMaxFunctionLines } : undefined; const spawnWorker = options?.workerFactory ?? diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index ced261b2b..f8fe4163e 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -42,7 +42,10 @@ import { registerRepo, cleanupOldKuzuFiles, INCREMENTAL_SCHEMA_VERSION, + type RepoMeta, } from '../storage/repo-manager.js'; +import { DEFAULT_PDG_MAX_FUNCTION_LINES } from './ingestion/cfg/collect.js'; +import { DEFAULT_MAX_CFG_EDGES_PER_FUNCTION } from './ingestion/cfg/emit.js'; import { computeFileHashes, diffFileHashes } from '../storage/file-hash.js'; import { extractChangedSubgraph, @@ -119,6 +122,19 @@ export interface AnalyzeOptions { noStats?: boolean; /** Skip installing standard GitNexus skill files to .claude/skills/gitnexus/. */ skipSkills?: boolean; + /** + * Build the CFG/PDG substrate (#2081 M1). Forwarded to `PipelineOptions.pdg`, + * which threads to BOTH the worker (CFG build, via workerData) AND + * scope-resolution (BasicBlock/CFG emit gate). Off by default. + */ + pdg?: boolean; + /** Per-function source-line cap for worker-side CFG construction (#2081 M1). + * Forwarded to `PipelineOptions.pdgMaxFunctionLines`. No CLI flag in M1 — + * programmatic / server analyze-worker path only; the worker applies + * `DEFAULT_PDG_MAX_FUNCTION_LINES` when unset. */ + pdgMaxFunctionLines?: number; + /** Per-function CFG edge cap. Forwarded to `PipelineOptions.pdgMaxEdgesPerFunction`. */ + pdgMaxEdgesPerFunction?: number; /** * Default branch threaded into generated AGENTS.md / CLAUDE.md so the * regression-compare example uses the configured branch instead of a @@ -313,6 +329,41 @@ export const collectBranchCacheKeys = async ( return { keys, complete }; }; +/** + * Resolve the requested `--pdg` configuration to the shape recorded in + * `RepoMeta.pdg`, or `undefined` for a pdg-off run. Caps resolve to their + * defaults so an explicit-default run compares equal to a default run + * (`0` = unlimited is preserved as `0`). Pure + exported for testing. + */ +type PdgOptions = Pick; + +export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] => + options.pdg === true + ? { + maxFunctionLines: options.pdgMaxFunctionLines ?? DEFAULT_PDG_MAX_FUNCTION_LINES, + maxEdgesPerFunction: options.pdgMaxEdgesPerFunction ?? DEFAULT_MAX_CFG_EDGES_PER_FUNCTION, + } + : undefined; + +/** + * Whether the requested `--pdg` configuration differs from the one the + * existing index's DB rows were built under (#2099 F1). An absent recorded + * stamp means pdg-off (every legacy meta — `--pdg` shipped opt-in). Any + * mismatch means the incremental writeback (which only persists changed-file + * nodes) cannot produce a coherent index: off→on would silently drop the + * freshly built CFG layer, on→off would strand zombie BasicBlocks — so the + * caller forces a full writeback. Pure + exported for testing. + */ +export const pdgModeMismatch = (recorded: RepoMeta['pdg'], options: PdgOptions): boolean => { + const requested = resolvePdgConfig(options); + if (!requested && !recorded) return false; + if (!requested || !recorded) return true; + return ( + requested.maxFunctionLines !== recorded.maxFunctionLines || + requested.maxEdgesPerFunction !== recorded.maxEdgesPerFunction + ); +}; + export async function runFullAnalysis( repoPath: string, options: AnalyzeOptions, @@ -473,7 +524,9 @@ export async function runFullAnalysis( // back to a known-good index is to wipe + rebuild from scratch. if (existingMeta?.incrementalInProgress) { log( - 'Previous incremental run did not complete cleanly (incrementalInProgress flag set); ' + + // "analyze run", not "incremental run" — since #2099 F1 the flag is a + // generic dirty marker written by BOTH writeback branches. + 'Previous analyze run did not complete cleanly (incrementalInProgress flag set); ' + 'forcing full rebuild to restore a known-good index.', ); options = { ...options, force: true }; @@ -482,6 +535,30 @@ export async function runFullAnalysis( // rebuild path executes. } + // ── pdg-mode flip forces full writeback (#2099 F1) ───────────────── + // The incremental writeback persists only changed-file nodes, so a pdg + // config differing from the one the DB rows were built under cannot be + // reconciled incrementally: off→on silently drops the freshly built CFG + // layer ("Incremental: changed=0", zero BasicBlock rows), on→off strands + // zombie blocks for unchanged files. MUST sit before the alreadyUpToDate + // fast path below — a clean-tree flip would otherwise early-return without + // running the pipeline at all. The notice is deliberately NOT gated on + // options.force: --skills implies force with no message of its own, and a + // mode change deserves a diagnostic regardless of why a rebuild happens. + if (existingMeta && pdgModeMismatch(existingMeta.pdg, options)) { + const pdgOn = options.pdg === true; + const capsOnly = !!existingMeta.pdg && pdgOn; // both-on can only mismatch via caps + const was = existingMeta.pdg ? 'with --pdg' : 'without --pdg'; + const now = pdgOn ? 'with --pdg' : 'without --pdg'; + log( + `pdg mode changed (index built ${was}, this run is ${now}` + + `${capsOnly ? ', but with different caps' : ''}); forcing a full ` + + `rebuild so the CFG layer is ${pdgOn ? 'fully persisted' : 'fully removed'}. ` + + `Tip: set \`pdg: ${pdgOn}\` in .gitnexusrc to pin the mode across runs.`, + ); + options = { ...options, force: true }; + } + // ── Early-return: already up to date ────────────────────────────── if (existingMeta && !options.force && existingMeta.lastCommit === currentCommit) { // Non-git folders have currentCommit = '' — always rebuild since we can't detect changes @@ -648,6 +725,11 @@ export async function runFullAnalysis( { parseCache, workerPoolSize: options.workerPoolSize, + // CFG/PDG opt-in (#2081 M1). PipelineOptions.pdg fans out to the worker + // build gate (workerData.pdg) and the scope-resolution emit gate. + pdg: options.pdg === true, + pdgMaxFunctionLines: options.pdgMaxFunctionLines, + pdgMaxEdgesPerFunction: options.pdgMaxEdgesPerFunction, fetchWrappers: options.fetchWrappers, }, ); @@ -706,6 +788,19 @@ export async function runFullAnalysis( }); } else { // Full rebuild path: wipe DB files first. + // Set the dirty flag BEFORE the wipe whenever a prior meta exists, + // mirroring the incremental branch above (#2099 F1, KTD2b). Without it a + // full rebuild crashing between the wipe and the end-of-run saveMeta + // leaves a meta that vouches for a DB it no longer matches — the next + // clean-tree run's fast path would certify a destroyed DB (or, after a + // pdg flip, certify zombie/missing BasicBlock rows indefinitely). + // toWriteCount: 0 is the full-path sentinel (no incremental write set). + if (existingMeta) { + await saveMeta(metaDir, { + ...existingMeta, + incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 }, + }); + } await closeLbug(); const lbugFiles = [lbugPath, `${lbugPath}.wal`, `${lbugPath}.lock`]; for (const f of lbugFiles) { @@ -1133,6 +1228,12 @@ export async function runFullAnalysis( // so a sibling branch's prune can union it and not evict our shards. cacheKeys: [...parseCache.usedKeys], incrementalInProgress: undefined as { startedAt: number; toWriteCount: number } | undefined, + // The effective pdg config this run's DB rows were built under + // (#2099 F1). `undefined` on pdg-off runs — this meta is a fresh + // literal (no spread of existingMeta), so omission is what CLEARS the + // stamp after an on→off flip; the next pdgModeMismatch then compares + // off==off and incremental eligibility is restored. + pdg: resolvePdgConfig(options), }; await saveMeta(metaDir, meta); diff --git a/gitnexus/src/storage/parse-cache.ts b/gitnexus/src/storage/parse-cache.ts index 0e683389d..744748e4b 100644 --- a/gitnexus/src/storage/parse-cache.ts +++ b/gitnexus/src/storage/parse-cache.ts @@ -55,7 +55,7 @@ import type { ParseWorkerResult } from '../core/ingestion/workers/parse-worker.j // the main thread (the #1983 OOM). Because the two stores share this version, // any future change to the `ParsedFile` serialization shape MUST bump // SCHEMA_BUMP so both invalidate in lockstep. -const SCHEMA_BUMP = 4; +const SCHEMA_BUMP = 5; // #2081 M1: ParsedFile gained `cfgSideChannel` const GITNEXUS_PKG_VERSION = (() => { try { // package.json sits at gitnexus/package.json — two levels up from @@ -141,12 +141,51 @@ export const fileContentHash = (content: Buffer | string): string => sha256Hex(c * in the chunk. We sort by filePath before hashing so chunks composed of * the same files in different order produce the same key. */ +/** PDG/CFG cache namespace (#2081 M1) — every input that changes the + * WORKER-EMITTED `cfgSideChannel` must be folded into the chunk key, and + * ONLY those. The classification test for a future option: does the worker + * see it (workerData) and does it change the bytes the worker writes to the + * shard? `pdgMaxEdgesPerFunction` famously fails that test — it is applied + * at EMIT time on the main thread (scope-resolution run.ts), the worker + * never receives it, and the cached output is byte-identical across cap + * values; folding it in (as a prior review round did) only forced a + * spurious full re-parse on every cap change (#2099 F3). Options that + * change the PERSISTED GRAPH but not the shard belong in the RepoMeta pdg + * stamp (incremental-eligibility), not here. */ +export interface PdgCacheKey { + readonly pdg?: boolean; + /** Per-function source-line cap (changes WHICH functions get a CFG — + * applied in the worker, so it shapes the cached shard). Callers must + * pass the RESOLVED value (the production call site in parse-impl.ts + * applies the worker's default before folding) so an explicit-default + * run shares the default run's keys — this function folds whatever it + * is given verbatim. */ + readonly maxFunctionLines?: number; +} + export const computeChunkHash = ( entries: Array<{ filePath: string; contentHash: string }>, + pdg: boolean | PdgCacheKey = false, ): string => { const sorted = [...entries].sort((a, b) => (a.filePath < b.filePath ? -1 : 1)); const joined = sorted.map((e) => `${e.filePath}:${e.contentHash}`).join('\n'); - return sha256Hex(joined); + const opts: PdgCacheKey = typeof pdg === 'boolean' ? { pdg } : pdg; + // pdg-off path keeps its pre-#2081 chunk-KEY format verbatim. Note this does + // NOT mean caches survive the M1 upgrade: SCHEMA_BUMP 4→5 changed + // PARSE_CACHE_VERSION, and both loadParseCache (below) and the durable + // parsedfile-store index hard-invalidate on it — every user pays one full + // cold re-parse on upgrade regardless of --pdg. Keeping the key format + // stable only means no SECOND invalidation class is introduced here. + if (!opts.pdg) return sha256Hex(joined); + // Fold the worker-visible --pdg configuration into the key: the boolean + // plus `maxFunctionLines` (decides which functions get a CFG at all, in the + // worker). Without it a warm chunk built under one cap is served to a run + // with a different cap → a stale/under-built CFG: the #2038-class + // option-blind-key trap. `def` marks an unset (default) value so two + // default-cap runs share a key. The emit-time edge cap is deliberately + // absent — see the PdgCacheKey doc comment. + const ns = `pdg:1;maxFn=${opts.maxFunctionLines ?? 'def'}`; + return sha256Hex(`${ns}\n${joined}`); }; /** diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index aeb0d9e63..80e815ebf 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -98,16 +98,18 @@ export interface RepoMeta { */ fileHashes?: Record; /** - * Crash-recovery dirty flag. Written to meta.json BEFORE any - * destructive DB mutation in an incremental run; cleared on success - * by overwriting meta.json. If a run crashes between, the next run - * sees the flag and forces a full rebuild — the cheapest path back - * to a known-good index. + * Crash-recovery dirty flag — a generic marker written to meta.json + * BEFORE any destructive DB mutation by BOTH writeback branches + * (incremental since its introduction; full rebuilds over an existing + * meta since #2099 F1); cleared on success by overwriting meta.json. + * If a run crashes between, the next run sees the flag and forces a + * full rebuild — the cheapest path back to a known-good index. */ incrementalInProgress?: { - /** When the incremental run started (epoch ms). */ + /** When the run started (epoch ms). */ startedAt: number; - /** Number of files in the writable set, for diagnostic logs. */ + /** Number of files in the writable set, for diagnostic logs. + * `0` on the full-rebuild path (no incremental write set exists). */ toWriteCount: number; }; /** @@ -127,6 +129,28 @@ export interface RepoMeta { * branch's still-live shards. Additive/optional; absent in legacy metas. */ cacheKeys?: string[]; + /** + * The effective `--pdg` configuration this index's DB rows were built + * under (#2099 F1). Presence ≡ the BasicBlock/CFG layer exists in the DB; + * ABSENT ≡ pdg-off — which covers every legacy meta, since `--pdg` + * shipped opt-in. Caps are recorded RESOLVED (defaults applied) so an + * explicit-default run compares equal to a default run. run-analyze + * compares this against the requested options and forces a full + * writeback on any mismatch — the incremental path only persists + * changed-file nodes and would otherwise silently drop (or strand) the + * CFG layer on a mode flip. Additive/optional, no + * INCREMENTAL_SCHEMA_VERSION bump (a bump would force a one-time full + * rebuild for every user). NOTE the removal mechanism is load-bearing: + * the end-of-run meta is a fresh object literal, NOT a spread of the + * prior meta, so omitting this field on a pdg-off run is what clears + * the stamp after an on→off flip. + */ + pdg?: { + /** Worker-side per-function source-line cap, resolved (0 = unlimited). */ + maxFunctionLines: number; + /** Emit-side per-function CFG edge cap, resolved (0 = unlimited). */ + maxEdgesPerFunction: number; + }; } /** diff --git a/gitnexus/test/helpers/mini-repo.ts b/gitnexus/test/helpers/mini-repo.ts new file mode 100644 index 000000000..0da74642e --- /dev/null +++ b/gitnexus/test/helpers/mini-repo.ts @@ -0,0 +1,53 @@ +/** + * Shared mini-repo fixture setup for `runFullAnalysis`-level integration + * tests (incremental-orchestration, pdg-mode-flip). Copies the + * `test/fixtures/mini-repo/src` files into a fresh git-initialized temp + * directory so each test owns a real repo with a real history. Extracted + * from incremental-orchestration.test.ts when pdg-mode-flip.test.ts became + * its second verbatim consumer (#2099) — the fixture file list must live in + * exactly one place. + */ + +import { execSync } from 'child_process'; +import { copyFile, mkdir } from 'fs/promises'; +import path from 'path'; +import { fileURLToPath } from 'url'; +import { createTempDir } from './test-db.js'; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const FIXTURE_SRC = path.resolve(HERE, '..', 'fixtures', 'mini-repo', 'src'); + +const MINI_REPO_FILES = [ + 'index.ts', + 'handler.ts', + 'validator.ts', + 'formatter.ts', + 'middleware.ts', + 'logger.ts', + 'db.ts', +]; + +/** + * Copy the mini-repo fixture into a fresh git-initialized temp directory. + * Returns the temp handle so the caller owns cleanup. + */ +export async function setupMiniRepo( + prefix = 'gitnexus-mini-repo-', +): Promise<{ dbPath: string; cleanup: () => Promise }> { + const tmp = await createTempDir(prefix); + const dest = path.join(tmp.dbPath, 'src'); + await mkdir(dest, { recursive: true }); + for (const n of MINI_REPO_FILES) { + await copyFile(path.join(FIXTURE_SRC, n), path.join(dest, n)); + } + execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' }); + execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', { + cwd: tmp.dbPath, + stdio: 'pipe', + }); + execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m initial', { + cwd: tmp.dbPath, + stdio: 'pipe', + }); + return tmp; +} diff --git a/gitnexus/test/integration/cfg/__snapshots__/cfg-snapshot.test.ts.snap b/gitnexus/test/integration/cfg/__snapshots__/cfg-snapshot.test.ts.snap new file mode 100644 index 000000000..092da4de9 --- /dev/null +++ b/gitnexus/test/integration/cfg/__snapshots__/cfg-snapshot.test.ts.snap @@ -0,0 +1,154 @@ +// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html + +exports[`U7 — AC1: 10-function fixture CFG snapshot > matches the committed CFG node/edge set 1`] = ` +[ + { + "blocks": 3, + "edges": [ + "0->2:seq", + "2->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 9, + }, + { + "blocks": 6, + "edges": [ + "0->2:seq", + "2->3:cond-true", + "2->4:cond-false", + "3->5:seq", + "4->5:seq", + "5->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 14, + }, + { + "blocks": 8, + "edges": [ + "0->2:seq", + "2->3:cond-true", + "2->4:cond-false", + "3->7:seq", + "4->5:cond-true", + "4->6:cond-false", + "5->7:seq", + "6->7:seq", + "7->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 23, + }, + { + "blocks": 6, + "edges": [ + "0->2:seq", + "2->3:cond-false", + "2->4:cond-true", + "3->5:seq", + "4->2:loop-back", + "5->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 34, + }, + { + "blocks": 8, + "edges": [ + "0->6:seq", + "2->3:cond-false", + "2->5:cond-true", + "3->7:seq", + "4->2:loop-back", + "5->4:seq", + "6->2:seq", + "7->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 41, + }, + { + "blocks": 6, + "edges": [ + "0->2:seq", + "2->3:cond-false", + "2->4:cond-true", + "3->5:seq", + "4->2:loop-back", + "5->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 48, + }, + { + "blocks": 10, + "edges": [ + "0->2:seq", + "2->4:switch-case", + "2->6:switch-case", + "2->8:switch-case", + "3->9:seq", + "4->5:seq", + "5->3:break", + "6->7:seq", + "7->3:break", + "8->3:fallthrough", + "9->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 55, + }, + { + "blocks": 6, + "edges": [ + "0->4:seq", + "2->5:seq", + "3->2:seq", + "4->2:seq", + "4->3:throw", + "5->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 69, + }, + { + "blocks": 5, + "edges": [ + "0->2:seq", + "2->3:cond-true", + "2->4:seq", + "3->1:return", + "4->1:return", + ], + "entry": 0, + "exit": 1, + "startLine": 80, + }, + { + "blocks": 8, + "edges": [ + "0->2:seq", + "2->3:cond-false", + "2->4:cond-true", + "3->7:seq", + "4->5:cond-true", + "4->6:cond-false", + "5->2:loop-back", + "6->2:loop-back", + "7->1:seq", + ], + "entry": 0, + "exit": 1, + "startLine": 87, + }, +] +`; diff --git a/gitnexus/test/integration/cfg/cfg-emit.test.ts b/gitnexus/test/integration/cfg/cfg-emit.test.ts new file mode 100644 index 000000000..d90c21210 --- /dev/null +++ b/gitnexus/test/integration/cfg/cfg-emit.test.ts @@ -0,0 +1,181 @@ +import { describe, it, expect, vi } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js'; +import { getProvider } from '../../../src/core/ingestion/languages/index.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; +import type { CfgVisitor, FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; +import type { KnowledgeGraph } from '../../../src/core/graph/types.js'; + +// U4 — emit BasicBlock nodes + CFG edges from the worker-built side-channel +// (R5, R6). Tests the pure emit helper against a recording graph: id shape +// (KTD3), edge `type`/`reason`, the AC2 reachability property, and the +// per-function edge cap's no-silent-truncation contract. The flag-gated +// run.ts wiring + full runPipelineFromRepo round-trip are covered in U7. + +interface RecordedNode { + id: string; + label: string; + properties: Record; +} +interface RecordedRel { + id: string; + type: string; + sourceId: string; + targetId: string; + reason: string; +} + +function recordingGraph(): { graph: KnowledgeGraph; nodes: RecordedNode[]; rels: RecordedRel[] } { + const nodes: RecordedNode[] = []; + const rels: RecordedRel[] = []; + const graph = { + addNode: (n: RecordedNode) => nodes.push(n), + addRelationship: (r: RecordedRel) => rels.push(r), + } as unknown as KnowledgeGraph; + return { graph, nodes, rels }; +} + +function tsRoot(code: string): SyntaxNode { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + return parser.parse(code).rootNode; +} + +const visitor = (): CfgVisitor => { + const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor; + if (!v) throw new Error('no cfgVisitor'); + return v; +}; + +const cfgsOf = (code: string, filePath = 'f.ts'): readonly FunctionCfg[] => + collectFunctionCfgs(tsRoot(code), visitor(), filePath).cfgs; + +describe('U4 — emitFileCfgs node/edge shape', () => { + it('emits BasicBlock nodes (KTD3 id, no name) + CFG edges carrying the kind in reason', () => { + const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } }`, 'src/f.ts'); + const { graph, nodes, rels } = recordingGraph(); + const r = emitFileCfgs(graph, cfgs); + + expect(r.blocks).toBe(nodes.length); + expect(r.edges).toBe(rels.length); + expect(nodes.length).toBeGreaterThan(0); + + // every node is a BasicBlock with the KTD3 id + // `BasicBlock::::` + for (const n of nodes) { + expect(n.label).toBe('BasicBlock'); + expect(n.id).toMatch(/^BasicBlock:src\/f\.ts:\d+:\d+:\d+$/); + expect(n.properties.filePath).toBe('src/f.ts'); + expect(n.properties.name).toBe(''); // no name column + } + // every edge is type 'CFG' and its reason is a CfgEdgeKind + const kinds = new Set(rels.map((e) => e.reason)); + expect(rels.every((e) => e.type === 'CFG')).toBe(true); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + }); + + it('block ids are unique across two functions in the same file (funcStart disambiguates)', () => { + const cfgs = cfgsOf(`function a() { x(); }\nfunction b() { y(); }`, 'm.ts'); + const { graph, nodes } = recordingGraph(); + emitFileCfgs(graph, cfgs); + const ids = nodes.map((n) => n.id); + expect(new Set(ids).size).toBe(ids.length); // no collisions + }); + + it('two functions sharing a start LINE get distinct ids (start-column disambiguates)', () => { + // Both arrows begin on line 1; without the start-column segment in the id + // their block indices (each restarting at 0) collide and graph.addNode's + // first-writer-wins silently drops the second function's blocks. + const cfgs = cfgsOf(`const h = { a: () => foo(), b: () => bar() };`, 'one-line.ts'); + expect(cfgs.length).toBe(2); + expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine); // same line + expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn); // diff column + const { graph, nodes } = recordingGraph(); + emitFileCfgs(graph, cfgs); + const ids = nodes.map((n) => n.id); + expect(new Set(ids).size).toBe(ids.length); // no collision despite shared line + expect(nodes.length).toBe(cfgs[0].blocks.length + cfgs[1].blocks.length); // all blocks survive + }); +}); + +describe('U4 — AC2: every BasicBlock is reachable from its function ENTRY', () => { + // Fixtures deliberately contain no dead code, so the reachability closure + // from each function's ENTRY (block index 0) must cover all of its blocks. + const FIXTURE = ` + function branch(x: number) { if (x) { a(); } else { b(); } c(); } + function loop(xs: number[]) { for (const y of xs) { use(y); } done(); } + function multi(x: number) { + switch (x) { case 1: one(); break; default: other(); } + tail(); + } + `; + + it('reachability closure from ENTRY covers every emitted block per function', () => { + const cfgs = cfgsOf(FIXTURE, 'r.ts'); + const { graph, nodes, rels } = recordingGraph(); + emitFileCfgs(graph, cfgs); + + const adj = new Map(); + for (const e of rels) + (adj.get(e.sourceId) ?? adj.set(e.sourceId, []).get(e.sourceId)!).push(e.targetId); + + for (const cfg of cfgs) { + const prefix = `BasicBlock:r.ts:${cfg.functionStartLine}:${cfg.functionStartColumn}:`; + const entryId = `${prefix}${cfg.entryIndex}`; + const fnNodeIds = nodes.map((n) => n.id).filter((id) => id.startsWith(prefix)); + // BFS from ENTRY + const seen = new Set([entryId]); + const stack = [entryId]; + while (stack.length) { + const n = stack.pop() as string; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + for (const id of fnNodeIds) { + expect(seen.has(id), `${id} unreachable from ENTRY`).toBe(true); + } + } + }); +}); + +describe('U4 — per-function edge cap (R6, no silent truncation)', () => { + it('stops at the cap, records the dropped count, and warns', () => { + const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } c(); }`); + const total = cfgs[0].edges.length; + expect(total).toBeGreaterThan(2); + + const { graph, rels } = recordingGraph(); + const onWarn = vi.fn(); + const r = emitFileCfgs(graph, cfgs, 2, onWarn); + + expect(rels.length).toBe(2); // emitted exactly the cap + expect(r.droppedEdges).toBe(total - 2); + expect(r.cappedFunctions).toBe(1); + expect(onWarn).toHaveBeenCalledTimes(1); + expect(onWarn.mock.calls[0][0]).toContain(`dropped ${total - 2} of ${total}`); + }); + + it('cap of 0 means unlimited (emits every edge, no warning)', () => { + const cfgs = cfgsOf(`function f(x: number) { if (x) { a(); } else { b(); } }`); + const { graph, rels } = recordingGraph(); + const onWarn = vi.fn(); + const r = emitFileCfgs(graph, cfgs, 0, onWarn); + expect(rels.length).toBe(cfgs[0].edges.length); + expect(r.droppedEdges).toBe(0); + expect(onWarn).not.toHaveBeenCalled(); + }); +}); + +describe('U4 — flag-off / empty input emits nothing', () => { + it('no functions ⇒ zero nodes and edges', () => { + const { graph, nodes, rels } = recordingGraph(); + const r = emitFileCfgs(graph, []); + expect(nodes).toHaveLength(0); + expect(rels).toHaveLength(0); + expect(r.blocks).toBe(0); + expect(r.edges).toBe(0); + }); +}); diff --git a/gitnexus/test/integration/cfg/cfg-snapshot.test.ts b/gitnexus/test/integration/cfg/cfg-snapshot.test.ts new file mode 100644 index 000000000..138fe4f30 --- /dev/null +++ b/gitnexus/test/integration/cfg/cfg-snapshot.test.ts @@ -0,0 +1,156 @@ +import { describe, it, expect } from 'vitest'; +import fs from 'fs'; +import path from 'path'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { emitFileCfgs } from '../../../src/core/ingestion/cfg/emit.js'; +import { getProvider } from '../../../src/core/ingestion/languages/index.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; +import type { CfgVisitor, FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; +import type { KnowledgeGraph } from '../../../src/core/graph/types.js'; + +// U7 — acceptance fixtures for the CFG layer (#2081 M1). +// AC1: the 10-function fixture's CFG node/edge set matches a committed snapshot. +// AC2: every BasicBlock is reachable from its function ENTRY (no dead code). +// AC3: try/throw/finally + labeled break/continue topologies are correct. +// (AC4 — flag-off byte-identical graph — is the existing +// pipeline-graph-golden.test.ts, run with --pdg off.) + +const FIXTURES = path.join(__dirname, 'fixtures'); + +const visitor = ((): CfgVisitor => { + const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor; + if (!v) throw new Error('no cfgVisitor'); + return v; +})(); + +function cfgsOfFile(file: string): readonly FunctionCfg[] { + const source = fs.readFileSync(path.join(FIXTURES, file), 'utf8'); + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + return collectFunctionCfgs(parser.parse(source).rootNode, visitor, file).cfgs; +} + +/** Deterministic, line-anchored serialization of a function's CFG. */ +function serialize(cfg: FunctionCfg): Record { + return { + startLine: cfg.functionStartLine, + blocks: cfg.blocks.length, + entry: cfg.entryIndex, + exit: cfg.exitIndex, + edges: cfg.edges.map((e) => `${e.from}->${e.to}:${e.kind}`).sort((x, y) => (x < y ? -1 : 1)), + }; +} + +interface Rel { + sourceId: string; + targetId: string; +} +function recordingGraph(): { graph: KnowledgeGraph; nodeIds: string[]; rels: Rel[] } { + const nodeIds: string[] = []; + const rels: Rel[] = []; + const graph = { + addNode: (n: { id: string }) => nodeIds.push(n.id), + addRelationship: (r: Rel) => rels.push(r), + } as unknown as KnowledgeGraph; + return { graph, nodeIds, rels }; +} + +function reaches(adj: Map, from: string, to: string): boolean { + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as string; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} + +describe('U7 — AC1: 10-function fixture CFG snapshot', () => { + it('matches the committed CFG node/edge set', () => { + const cfgs = cfgsOfFile('ten-functions.ts'); + expect(cfgs).toHaveLength(10); + expect(cfgs.map(serialize)).toMatchSnapshot(); + }); +}); + +describe('U7 — AC2: every BasicBlock reachable from its function ENTRY', () => { + it('holds for all ten functions (no dead code in the fixture)', () => { + const cfgs = cfgsOfFile('ten-functions.ts'); + const { graph, nodeIds, rels } = recordingGraph(); + emitFileCfgs(graph, cfgs); + + const adj = new Map(); + for (const e of rels) + (adj.get(e.sourceId) ?? adj.set(e.sourceId, []).get(e.sourceId)!).push(e.targetId); + + for (const cfg of cfgs) { + const prefix = `BasicBlock:ten-functions.ts:${cfg.functionStartLine}:${cfg.functionStartColumn}:`; + const entryId = `${prefix}${cfg.entryIndex}`; + for (const id of nodeIds.filter((i) => i.startsWith(prefix))) { + expect(reaches(adj, entryId, id), `${id} unreachable from ENTRY`).toBe(true); + } + } + }); +}); + +describe('U7 — AC3: hazard topologies', () => { + function blockAdj(cfg: FunctionCfg): { adj: Map } { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + return { adj }; + } + const blockWith = (cfg: FunctionCfg, sub: string): number => { + const b = cfg.blocks.find((bl) => bl.text.includes(sub)); + if (!b) throw new Error(`no block with ${sub}`); + return b.index; + }; + const reachIdx = (cfg: FunctionCfg, from: number, to: number): boolean => { + const { adj } = blockAdj(cfg); + const seen = new Set([from]); + const st = [from]; + while (st.length) { + const n = st.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), st.push(nx)); + } + return false; + }; + + it('try/throw/finally: normal + exceptional both flow through finally to the post-try block', () => { + const cfgs = cfgsOfFile('hazards.ts'); + const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('cleanup();')))!; + const fin = blockWith(fn, 'cleanup();'); + const after = blockWith(fn, 'afterTry();'); + const work = blockWith(fn, 'work();'); + const handler = blockWith(fn, 'handle();'); + expect(fn.edges.some((e) => e.kind === 'throw')).toBe(true); + expect(reachIdx(fn, work, fin)).toBe(true); // normal path → finally + expect(reachIdx(fn, work, handler)).toBe(true); // exceptional → catch + expect(reachIdx(fn, handler, fin)).toBe(true); // catch → finally + expect(reachIdx(fn, fin, after)).toBe(true); // finally → continuation + }); + + it('labeled break escapes both loops to the post-loop block, not the inner exit', () => { + const cfgs = cfgsOfFile('hazards.ts'); + const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('break outer;')))!; + const brk = blockWith(fn, 'break outer;'); + expect(reachIdx(fn, brk, blockWith(fn, 'done();'))).toBe(true); + expect(reachIdx(fn, brk, blockWith(fn, 'afterInner();'))).toBe(false); + expect(fn.edges.some((e) => e.kind === 'break')).toBe(true); + }); + + it('labeled continue returns to the OUTER loop header (not the nearest)', () => { + const cfgs = cfgsOfFile('hazards.ts'); + const fn = cfgs.find((c) => c.blocks.some((b) => b.text.includes('continue outer;')))!; + const cont = blockWith(fn, 'continue outer;'); + // outer loop iterates `xs`; its header is the only block whose text holds "xs" + const outerHeader = blockWith(fn, 'xs'); + expect( + fn.edges.some((e) => e.from === cont && e.to === outerHeader && e.kind === 'continue'), + ).toBe(true); + }); +}); diff --git a/gitnexus/test/integration/cfg/fixtures/hazards.ts b/gitnexus/test/integration/cfg/fixtures/hazards.ts new file mode 100644 index 000000000..46632acad --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/hazards.ts @@ -0,0 +1,52 @@ +// AC3 fixtures (#2081 M1): the classic CFG hazards — try/throw/finally +// post-domination and labeled break/continue across nested loops. The visitor +// must route both normal completion AND an exception through `finally`, and +// resolve labeled jumps against the labeled (outer) loop, not the nearest one. + +export function tryThrowFinally(flag: boolean): void { + try { + work(); + if (flag) { + throw new Error('boom'); + } + } catch (e) { + handle(); + } finally { + cleanup(); + } + afterTry(); +} + +export function labeledBreak(xs: number[], ys: number[]): void { + outer: for (const x of xs) { + for (const y of ys) { + if (x === y) { + break outer; + } + inner(); + } + afterInner(); + } + done(); +} + +export function labeledContinue(xs: number[], ys: number[]): void { + outer: for (const x of xs) { + for (const y of ys) { + if (x === y) { + continue outer; + } + body(); + } + } + done(); +} + +declare function work(): void; +declare function handle(): void; +declare function cleanup(): void; +declare function afterTry(): void; +declare function inner(): void; +declare function afterInner(): void; +declare function body(): void; +declare function done(): void; diff --git a/gitnexus/test/integration/cfg/fixtures/pdg-repo/sample.ts b/gitnexus/test/integration/cfg/fixtures/pdg-repo/sample.ts new file mode 100644 index 000000000..e49bf9097 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/pdg-repo/sample.ts @@ -0,0 +1,22 @@ +// Tiny repo fixture for the end-to-end --pdg pipeline test (#2081 M1). +// Two functions with branches/loops so the emitted CFG has multiple +// BasicBlock nodes and several CFG edge kinds. + +export function classify(x: number): string { + if (x > 0) { + return 'pos'; + } else if (x < 0) { + return 'neg'; + } + return 'zero'; +} + +export function total(xs: number[]): number { + let sum = 0; + for (const x of xs) { + if (x > 0) { + sum += x; + } + } + return sum; +} diff --git a/gitnexus/test/integration/cfg/fixtures/ten-functions.ts b/gitnexus/test/integration/cfg/fixtures/ten-functions.ts new file mode 100644 index 000000000..5a7a6db2a --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/ten-functions.ts @@ -0,0 +1,115 @@ +// AC1 fixture (#2081 M1): ten TS functions mixing the control-flow constructs +// the CFG visitor handles. Deliberately NO unreachable/dead code (no statement +// after an unconditional return/throw), so the AC2 property — every BasicBlock +// reachable from its function ENTRY — holds for every function here. +// +// Edit with care: the CFG snapshot (cfg-snapshot.test.ts) keys block/edge +// counts off this exact source, and the per-function start lines anchor it. + +export function straight(): void { + a(); + b(); +} + +export function withIf(x: boolean): void { + if (x) { + a(); + } else { + b(); + } + c(); +} + +export function withElseIf(x: number): void { + if (x === 1) { + a(); + } else if (x === 2) { + b(); + } else { + c(); + } + d(); +} + +export function withWhile(x: number): void { + while (x > 0) { + step(); + } + done(); +} + +export function withFor(n: number): void { + for (let i = 0; i < n; i++) { + step(); + } + done(); +} + +export function withForOf(xs: number[]): void { + for (const x of xs) { + use(x); + } + done(); +} + +export function withSwitch(x: number): void { + switch (x) { + case 1: + one(); + break; + case 2: + two(); + break; + default: + other(); + } + tail(); +} + +export function withTry(): void { + try { + work(); + } catch (e) { + oops(); + } finally { + fin(); + } + after(); +} + +export function withReturn(x: boolean): number { + if (x) { + return 1; + } + return 2; +} + +export function withNested(xs: number[]): void { + for (const x of xs) { + if (x > 0) { + p(); + } else { + q(); + } + } + end(); +} + +declare function a(): void; +declare function b(): void; +declare function c(): void; +declare function d(): void; +declare function step(): void; +declare function done(): void; +declare function use(x: number): void; +declare function one(): void; +declare function two(): void; +declare function other(): void; +declare function tail(): void; +declare function work(): void; +declare function oops(): void; +declare function fin(): void; +declare function after(): void; +declare function p(): void; +declare function q(): void; +declare function end(): void; diff --git a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts new file mode 100644 index 000000000..35666fe7d --- /dev/null +++ b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, afterAll } from 'vitest'; +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; +import type { PipelineResult } from '../../../src/types/pipeline.js'; + +// U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse +// worker builds a per-function CFG (workerData.pdg) and scope-resolution emits +// BasicBlock nodes + CFG edges from it (the run gate). Runs the real pipeline +// (workers + scope-resolution) on a tiny repo and inspects the in-memory graph. +// The flag-off run proves the gate: zero CFG nodes/edges (cf. AC4 golden). + +const FIXTURE = path.join(__dirname, 'fixtures', 'pdg-repo'); + +function counts(result: PipelineResult): { basicBlocks: number; cfgEdges: number } { + let basicBlocks = 0; + result.graph.forEachNode((n) => { + if (n.label === 'BasicBlock') basicBlocks++; + }); + let cfgEdges = 0; + for (const rel of result.graph.iterRelationships()) { + if (rel.type === 'CFG') cfgEdges++; + } + return { basicBlocks, cfgEdges }; +} + +const tmpDirs: string[] = []; +function freshRepo(): string { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-')); + fs.cpSync(FIXTURE, dir, { recursive: true }); + tmpDirs.push(dir); + return dir; +} + +describe('U7 — end-to-end --pdg pipeline', () => { + afterAll(() => { + for (const d of tmpDirs) fs.rmSync(d, { recursive: true, force: true }); + }); + + it('with --pdg on: emits BasicBlock nodes + CFG edges into the graph', async () => { + const result = await runPipelineFromRepo(freshRepo(), () => {}, { pdg: true }); + const { basicBlocks, cfgEdges } = counts(result); + expect(basicBlocks).toBeGreaterThan(0); + expect(cfgEdges).toBeGreaterThan(0); + // CFG edges connect BasicBlocks to BasicBlocks — both endpoints exist. + const blockIds = new Set(); + result.graph.forEachNode((n) => { + if (n.label === 'BasicBlock') blockIds.add(n.id); + }); + for (const rel of result.graph.iterRelationships()) { + if (rel.type !== 'CFG') continue; + expect(blockIds.has(rel.sourceId)).toBe(true); + expect(blockIds.has(rel.targetId)).toBe(true); + } + }, 60000); + + it('with --pdg off (default): emits zero BasicBlock nodes and zero CFG edges', async () => { + const result = await runPipelineFromRepo(freshRepo(), () => {}); + const { basicBlocks, cfgEdges } = counts(result); + expect(basicBlocks).toBe(0); + expect(cfgEdges).toBe(0); + }, 60000); +}); diff --git a/gitnexus/test/integration/cfg/worker-roundtrip.test.ts b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts new file mode 100644 index 000000000..6589b3b02 --- /dev/null +++ b/gitnexus/test/integration/cfg/worker-roundtrip.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js'; +import { computeChunkHash, mapReplacer, mapReviver } from '../../../src/storage/parse-cache.js'; +import { getProvider } from '../../../src/core/ingestion/languages/index.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; +import type { CfgVisitor } from '../../../src/core/ingestion/cfg/types.js'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; + +// U3 — the worker→main boundary + cache coherence for the CFG side-channel. +// These pin the contracts that make the disk-store + warm/durable parse cache +// carry the CFG intact across the --pdg flag (R3, R4) WITHOUT spinning a real +// worker pool: the worker simply calls collectFunctionCfgs (tested here) and +// attaches the result as plain data, and the parse-cache key folds the flag. + +function tsRoot(code: string): SyntaxNode { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + return parser.parse(code).rootNode; +} + +const tsVisitor = (): CfgVisitor => { + const v = getProvider(SupportedLanguages.TypeScript).cfgVisitor; + if (!v) throw new Error('typescript provider has no cfgVisitor'); + return v; +}; + +describe('U3 — TS/JS provider exposes a cfgVisitor; others do not (worker gate)', () => { + it('TS and JS providers carry a cfgVisitor', () => { + expect(getProvider(SupportedLanguages.TypeScript).cfgVisitor).toBeDefined(); + expect(getProvider(SupportedLanguages.JavaScript).cfgVisitor).toBeDefined(); + }); + + it('a non-CFG language (Python) has no cfgVisitor ⇒ worker emits no cfgSideChannel', () => { + // `provider.cfgVisitor &&` short-circuits in the worker → no CFG, no field. + expect(getProvider(SupportedLanguages.Python).cfgVisitor).toBeUndefined(); + }); +}); + +describe('U3 — collectFunctionCfgs', () => { + it('produces one CFG per function with the expected branch edges', () => { + const root = tsRoot(` + function a(x: number) { if (x) { p(); } else { q(); } } + function b() { return 1; } + `); + const { cfgs, skipped } = collectFunctionCfgs(root, tsVisitor(), 'a.ts'); + expect(skipped).toBe(0); + expect(cfgs).toHaveLength(2); + const a = cfgs.find((c) => c.blocks.some((bl) => bl.text.includes('p();'))); + expect(a).toBeDefined(); + const kinds = new Set(a!.edges.map((e) => e.kind)); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + // every block belongs to its declaring file + for (const c of cfgs) expect(c.filePath).toBe('a.ts'); + }); + + it('a file with no functions yields an empty CFG set (no error)', () => { + const { cfgs, skipped } = collectFunctionCfgs( + tsRoot(`const x = 1; export {};`), + tsVisitor(), + 'x.ts', + ); + expect(cfgs).toHaveLength(0); + expect(skipped).toBe(0); + }); + + it('maxFunctionLines skips an over-cap function and counts the skip', () => { + const big = `function big() {\n${' step();\n'.repeat(20)}}`; + const root = tsRoot(`${big}\nfunction small() { ok(); }`); + const { cfgs, skipped } = collectFunctionCfgs(root, tsVisitor(), 'f.ts', 5); + expect(skipped).toBe(1); // big() exceeds the 5-line cap + // small() is still built + expect(cfgs.some((c) => c.blocks.some((bl) => bl.text.includes('ok();')))).toBe(true); + expect(cfgs.some((c) => c.blocks.some((bl) => bl.text.includes('step();')))).toBe(false); + }); +}); + +describe('U3 — CFG side-channel JSON round-trip (no AST leakage, no field loss)', () => { + it('serialize → JSON → deserialize yields an identical CFG', () => { + const root = tsRoot(`function f(xs: number[]) { + for (const x of xs) { if (x > 0) { use(x); } else { break; } } + done(); + }`); + const { cfgs } = collectFunctionCfgs(root, tsVisitor(), 'rt.ts'); + expect(cfgs.length).toBeGreaterThan(0); + // The worker serializes ParsedFile via mapReplacer; the store revives via + // mapReviver. The CFG is plain data, so it must survive byte-for-byte. + const round = JSON.parse(JSON.stringify(cfgs, mapReplacer), mapReviver); + expect(round).toEqual(cfgs); + // No tree-sitter nodes leaked: every value is a primitive/array/plain object. + for (const c of round) { + for (const b of c.blocks) expect(typeof b.text).toBe('string'); + for (const e of c.edges) expect(typeof e.from).toBe('number'); + } + }); +}); + +describe('U3 — parse-cache key folds the --pdg flag (R4, #2038-class guard)', () => { + const entries = [ + { filePath: 'b.ts', contentHash: 'h2' }, + { filePath: 'a.ts', contentHash: 'h1' }, + ]; + + it('pdg-on and pdg-off produce DIFFERENT chunk keys', () => { + expect(computeChunkHash(entries, false)).not.toBe(computeChunkHash(entries, true)); + }); + + it('the same flag value is stable and order-independent', () => { + const reordered = [...entries].reverse(); + expect(computeChunkHash(entries, true)).toBe(computeChunkHash(reordered, true)); + expect(computeChunkHash(entries, false)).toBe(computeChunkHash(reordered, false)); + }); + + it('default (no flag arg) equals the explicit pdg-off key — warm caches survive the change', () => { + expect(computeChunkHash(entries)).toBe(computeChunkHash(entries, false)); + }); + + it('the boolean form equals the object form with the same flag (back-compat)', () => { + expect(computeChunkHash(entries, true)).toBe(computeChunkHash(entries, { pdg: true })); + expect(computeChunkHash(entries, false)).toBe(computeChunkHash(entries, { pdg: false })); + }); + + it('the worker-side line cap is folded into the key — a different maxFunctionLines re-dispatches', () => { + // Guards the #2038-class trap for the WORKER-visible cap: a warm chunk + // built under one maxFunctionLines must NOT be served to a --pdg run with + // a different cap (the cached cfgSideChannel differs — the worker skips + // different functions). Different cap value ⇒ different key. + const base = computeChunkHash(entries, { pdg: true }); + expect(computeChunkHash(entries, { pdg: true, maxFunctionLines: 500 })).not.toBe(base); + // Same cap values ⇒ same key (deterministic, order-independent). + const reordered = [...entries].reverse(); + expect(computeChunkHash(entries, { pdg: true, maxFunctionLines: 500 })).toBe( + computeChunkHash(reordered, { pdg: true, maxFunctionLines: 500 }), + ); + }); + + it('the EMIT-time edge cap does NOT perturb the key — cached worker output is identical across it (#2099 F3)', () => { + // pdgMaxEdgesPerFunction is applied in scope-resolution on the main + // thread; the worker never sees it, so the cached shard is byte-identical + // across cap values. Folding it in (a prior review round did) only forced + // a spurious full re-parse + durable-store rewrite on every cap change. + const base = computeChunkHash(entries, { pdg: true }); + expect( + computeChunkHash(entries, { + pdg: true, + maxEdgesPerFunction: 100, + } as Parameters[1]), + ).toBe(base); + }); +}); diff --git a/gitnexus/test/unit/analyze-config.test.ts b/gitnexus/test/unit/analyze-config.test.ts index 0c48628cf..00637b56f 100644 --- a/gitnexus/test/unit/analyze-config.test.ts +++ b/gitnexus/test/unit/analyze-config.test.ts @@ -75,6 +75,17 @@ describe('analyze-config (.gitnexusrc support, #243)', () => { }); }); + it('normalizes the pdg opt-in (#2081) and rejects a non-boolean value', async () => { + await writeRc(JSON.stringify({ pdg: true })); + expect(loadAnalyzeConfig(dir)).toEqual({ pdg: true }); + + await writeRc(JSON.stringify({ pdg: false })); + expect(loadAnalyzeConfig(dir)).toEqual({ pdg: false }); + + await writeRc(JSON.stringify({ pdg: 'yes' })); + expect(() => loadAnalyzeConfig(dir)).toThrow(/must be a boolean/); + }); + it('parses the nested analyze form', async () => { await writeRc(JSON.stringify({ analyze: { defaultBranch: 'master', skipSkills: true } })); expect(loadAnalyzeConfig(dir)).toEqual({ defaultBranch: 'master', skipSkills: true }); diff --git a/gitnexus/test/unit/cfg/cfg-builder.test.ts b/gitnexus/test/unit/cfg/cfg-builder.test.ts new file mode 100644 index 000000000..f3738af14 --- /dev/null +++ b/gitnexus/test/unit/cfg/cfg-builder.test.ts @@ -0,0 +1,120 @@ +import { describe, it, expect } from 'vitest'; +import { CfgBuilder, reachableBlocks } from '../../../src/core/ingestion/cfg/cfg-builder.js'; +import { ControlFlowContext } from '../../../src/core/ingestion/cfg/control-flow-context.js'; + +// The CFG core is AST-agnostic — these tests drive the builder + context the +// way the TS/JS visitor (U2) will, on the classic control-flow topologies the +// S2 spike validated. They pin block/edge accounting and reachability (R1, R9) +// before any tree-sitter coupling exists. + +describe('CfgBuilder', () => { + it('straight-line body: ENTRY → block → EXIT, all reachable', () => { + const b = new CfgBuilder('f.ts', 1, 3); + const body = b.newBlock(2, 2, 'g();'); + b.edge(b.entryIndex, body, 'seq'); + b.edge(body, b.exitIndex, 'seq'); + const cfg = b.finish(); + expect(cfg.blocks).toHaveLength(3); // entry, exit, body + expect(cfg.entryIndex).toBe(0); + expect(reachableBlocks(cfg).size).toBe(3); + }); + + it('empty function: ENTRY → EXIT only', () => { + const b = new CfgBuilder('f.ts', 1, 1); + b.edge(b.entryIndex, b.exitIndex, 'seq'); + const cfg = b.finish(); + expect(cfg.blocks).toHaveLength(2); + expect(reachableBlocks(cfg)).toEqual(new Set([b.entryIndex, b.exitIndex])); + }); + + it('if/else diamond: both branches reach the join', () => { + const b = new CfgBuilder('f.ts', 1, 6); + const thenB = b.newBlock(2, 2, 'a();'); + const elseB = b.newBlock(4, 4, 'b();'); + const join = b.newBlock(6, 6, 'c();'); + b.edge(b.entryIndex, thenB, 'cond-true'); + b.edge(b.entryIndex, elseB, 'cond-false'); + b.connect([thenB, elseB], join, 'seq'); + b.edge(join, b.exitIndex, 'seq'); + const cfg = b.finish(); + const reach = reachableBlocks(cfg); + expect(reach.has(thenB) && reach.has(elseB) && reach.has(join)).toBe(true); + // join has two predecessors + expect(cfg.edges.filter((e) => e.to === join)).toHaveLength(2); + }); + + it('while loop: body back-edges to header; header exits the loop', () => { + const b = new CfgBuilder('f.ts', 1, 4); + const header = b.newBlock(1, 1, 'while(x)'); + const body = b.newBlock(2, 2, 'x--;'); + b.edge(b.entryIndex, header, 'seq'); + b.edge(header, body, 'cond-true'); + b.edge(body, header, 'loop-back'); + b.edge(header, b.exitIndex, 'cond-false'); + const cfg = b.finish(); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(reachableBlocks(cfg).size).toBe(4); // all reachable + }); + + it('mid-block return wires to EXIT; trailing block still emitted but unreachable-by-fallthrough', () => { + const b = new CfgBuilder('f.ts', 1, 4); + const ret = b.newBlock(2, 2, 'return 1;'); + const dead = b.newBlock(3, 3, 'g();'); // after return — no fallthrough edge into it + b.edge(b.entryIndex, ret, 'seq'); + b.edge(ret, b.exitIndex, 'return'); + b.edge(dead, b.exitIndex, 'seq'); + const cfg = b.finish(); + const reach = reachableBlocks(cfg); + expect(reach.has(ret)).toBe(true); + expect(reach.has(dead)).toBe(false); // emitted, but not reachable from ENTRY + }); + + it('edge() is idempotent on (from,to,kind)', () => { + const b = new CfgBuilder('f.ts', 1, 2); + const x = b.newBlock(1, 1, 'x'); + b.edge(b.entryIndex, x, 'seq'); + b.edge(b.entryIndex, x, 'seq'); // duplicate + b.connect([b.entryIndex], x, 'seq'); // duplicate via connect + expect(b.finish().edges.filter((e) => e.from === b.entryIndex && e.to === x)).toHaveLength(1); + }); + + it('finish() indexes blocks contiguously from 0', () => { + const b = new CfgBuilder('f.ts', 1, 2); + b.newBlock(1, 1, 'a'); + b.newBlock(2, 2, 'b'); + const cfg = b.finish(); + expect(cfg.blocks.map((bl) => bl.index)).toEqual([0, 1, 2, 3]); + expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry'); + expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit'); + }); +}); + +describe('ControlFlowContext', () => { + it('plain break/continue resolve to the nearest loop', () => { + const ctx = new ControlFlowContext(); + ctx.pushLoop(/*continueTo*/ 10, /*breakTo*/ 20); + expect(ctx.continueTarget()).toBe(10); + expect(ctx.breakTarget()).toBe(20); + ctx.pop(); + expect(ctx.breakTarget()).toBeUndefined(); + }); + + it('break resolves to the nearest switch; continue skips switches to the loop', () => { + const ctx = new ControlFlowContext(); + ctx.pushLoop(100, 200); // outer loop + ctx.pushSwitch(30); // inner switch + expect(ctx.breakTarget()).toBe(30); // break → switch + expect(ctx.continueTarget()).toBe(100); // continue skips switch → loop + ctx.pop(); + expect(ctx.breakTarget()).toBe(200); + }); + + it('labeled break/continue resolve to the labeled loop, not the nearest', () => { + const ctx = new ControlFlowContext(); + ctx.pushLoop(/*outer*/ 100, 200, 'outer'); + ctx.pushLoop(/*inner*/ 110, 210); + expect(ctx.breakTarget('outer')).toBe(200); + expect(ctx.continueTarget('outer')).toBe(100); + expect(ctx.breakTarget()).toBe(210); // unlabeled → nearest (inner) + }); +}); diff --git a/gitnexus/test/unit/cfg/emit-guard.test.ts b/gitnexus/test/unit/cfg/emit-guard.test.ts new file mode 100644 index 000000000..da71def1a --- /dev/null +++ b/gitnexus/test/unit/cfg/emit-guard.test.ts @@ -0,0 +1,193 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import type { ParsedFile, ScopeId, Scope } from 'gitnexus-shared'; +import { runScopeResolution } from '../../../src/core/ingestion/scope-resolution/pipeline/run.js'; +import { createKnowledgeGraph } from '../../../src/core/graph/graph.js'; +import { createSemanticModel } from '../../../src/core/ingestion/model/semantic-model.js'; +import type { ScopeResolver } from '../../../src/core/ingestion/scope-resolution/contract/scope-resolver.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import type { KnowledgeGraph } from '../../../src/core/graph/types.js'; +import { _captureLogger } from '../../../src/core/logger.js'; + +// #2099 F4 — the per-element cfgSideChannel guard at the scope-resolution emit +// site. A malformed element (wrong shape, non-integer edge endpoints, or a +// shape that throws inside emitFileCfgs) must cost at most that element's / +// that file's CFG — never abort the language's scope-resolution pass, and +// never silently emit a dangling `BasicBlock:…:undefined` edge id. Harness: +// runScopeResolution with a stub provider + preExtractedParsedFiles, the same +// trio as run-progress.test.ts (no cfgVisitor needed — the emit path reads the +// channel directly). + +const mkScope = (id: ScopeId, filePath: string): Scope => ({ + id, + parent: null, + kind: 'Module', + range: { startLine: 1, startCol: 0, endLine: 10, endCol: 0 }, + filePath, + bindings: new Map(), + ownedDefs: [], + imports: [], + typeBindings: new Map(), +}); + +const mkFile = (filePath: string, cfgSideChannel?: unknown): ParsedFile => ({ + filePath, + moduleScope: `scope:${filePath}#module`, + scopes: [mkScope(`scope:${filePath}#module`, filePath)], + parsedImports: [], + localDefs: [], + referenceSites: [], + ...(cfgSideChannel !== undefined ? { cfgSideChannel } : {}), +}); + +const stubProvider = { + language: 'python' as const, + languageProvider: {} as ScopeResolver['languageProvider'], + importEdgeReason: 'test', + populateOwners: () => {}, + resolveImportTarget: () => null, + mergeBindings: (existing: unknown) => existing, + buildMro: () => new Map(), + propagatesReturnTypesAcrossImports: false, +} as unknown as ScopeResolver; + +const validCfg: FunctionCfg = { + filePath: 'a.py', + functionStartLine: 1, + functionEndLine: 3, + functionStartColumn: 0, + entryIndex: 0, + exitIndex: 1, + blocks: [ + { index: 0, startLine: 1, endLine: 1, text: '', kind: 'entry' }, + { index: 1, startLine: 3, endLine: 3, text: '', kind: 'exit' }, + ], + edges: [{ from: 0, to: 1, kind: 'seq' }], +}; + +/** Run scope-resolution with `pdg: true` over one file carrying `channel`. */ +function emitWith(channel: unknown): KnowledgeGraph { + const graph = createKnowledgeGraph(); + const files = [{ path: 'a.py', content: '' }]; + const preExtracted = new Map([['a.py', mkFile('a.py', channel)]]); + runScopeResolution( + { + graph, + model: createSemanticModel(), + files, + preExtractedParsedFiles: preExtracted, + pdg: true, + }, + stubProvider, + ); + return graph; +} + +const basicBlockCount = (graph: KnowledgeGraph): number => { + let n = 0; + graph.forEachNode((node) => { + if (node.label === 'BasicBlock') n++; + }); + return n; +}; + +const cfgEdges = (graph: KnowledgeGraph): { sourceId: string; targetId: string }[] => { + const out: { sourceId: string; targetId: string }[] = []; + graph.forEachRelationship((r) => { + if (r.type === 'CFG') out.push({ sourceId: r.sourceId, targetId: r.targetId }); + }); + return out; +}; + +describe('cfgSideChannel emit guard (#2099 F4)', () => { + let cap: ReturnType; + + beforeEach(() => { + cap = _captureLogger(); + }); + afterEach(() => { + cap.restore(); + }); + + const warns = (): string[] => + cap + .records() + .filter((r) => r.level >= 40) // pino warn = 40 + .map((r) => String(r.msg)); + + it('a wrong-shape element [{}] is skipped with a warning naming the file; no throw', () => { + const graph = emitWith([{}]); + expect(basicBlockCount(graph)).toBe(0); + expect(warns()).toHaveLength(1); + expect(warns()[0]).toContain('a.py'); + }); + + it('a non-array channel is silently skipped by the outer guard', () => { + const graph = emitWith('garbage'); + expect(basicBlockCount(graph)).toBe(0); + expect(warns()).toHaveLength(0); + }); + + it('mixed array: the valid element still emits, the malformed one is skipped (per-element policy)', () => { + const graph = emitWith([validCfg, {}]); + expect(basicBlockCount(graph)).toBe(2); + expect(cfgEdges(graph)).toHaveLength(1); + expect(warns()).toHaveLength(1); + }); + + it('non-integer edge endpoints are rejected by the PREDICATE — zero dangling edge ids (this shape never throws)', () => { + const poisoned = { ...validCfg, edges: [{ from: 'x', to: 1, kind: 'seq' }] }; + const graph = emitWith([poisoned]); + expect(basicBlockCount(graph)).toBe(0); + expect(cfgEdges(graph)).toHaveLength(0); + expect(warns()).toHaveLength(1); + }); + + it('an INTEGER endpoint matching no block index is rejected too — membership, not just integer-ness', () => { + const poisoned = { ...validCfg, edges: [{ from: 0, to: 7, kind: 'seq' }] }; + const graph = emitWith([poisoned]); + expect(basicBlockCount(graph)).toBe(0); + expect(cfgEdges(graph)).toHaveLength(0); + expect(warns()).toHaveLength(1); + }); + + it('missing id-anchor fields (functionStartColumn) are rejected — prevents first-writer-wins id cross-wiring', () => { + const { functionStartColumn: _drop, ...withoutColumn } = validCfg; + const graph = emitWith([withoutColumn]); + expect(basicBlockCount(graph)).toBe(0); + expect(warns()).toHaveLength(1); + }); + + it('a null element inside blocks is rejected by the predicate — no partial emit, no orphaned nodes', () => { + const poisoned = { ...validCfg, blocks: [validCfg.blocks[0], null] }; + const graph = emitWith([poisoned]); + expect(basicBlockCount(graph)).toBe(0); // nothing emitted — not even the valid leading block + expect(warns()).toHaveLength(1); + }); + + it('backstop: a shape that throws past the predicate (hostile getter) is caught, warned, pass completes', () => { + const hostile = { + ...validCfg, + blocks: [ + { + get index(): number { + throw new Error('hostile getter'); + }, + startLine: 1, + endLine: 1, + text: '', + kind: 'normal' as const, + }, + ], + }; + expect(() => emitWith([hostile])).not.toThrow(); + expect(warns()).toHaveLength(1); + expect(warns()[0]).toContain('CFG emission failed'); + }); + + it('a well-formed channel emits blocks + edges identically (regression guard)', () => { + const graph = emitWith([validCfg]); + expect(basicBlockCount(graph)).toBe(2); + expect(cfgEdges(graph)).toHaveLength(1); + expect(warns()).toHaveLength(0); + }); +}); diff --git a/gitnexus/test/unit/cfg/opt-in.test.ts b/gitnexus/test/unit/cfg/opt-in.test.ts new file mode 100644 index 000000000..11a0d45fa --- /dev/null +++ b/gitnexus/test/unit/cfg/opt-in.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { mergeAnalyzeOptions } from '../../../src/cli/analyze-config.js'; +import { computeChunkHash } from '../../../src/storage/parse-cache.js'; + +// U5 — the `--pdg` opt-in plumbing (R7). The flag has TWO sinks downstream of +// PipelineOptions.pdg: the parse worker (CFG build, gated on workerData.pdg) +// and scope-resolution (BasicBlock/CFG emit, gated on the run input). These +// tests pin the observable plumbing: the CLI/.gitnexusrc merge, and that the +// flag perturbs the parse-cache/worker-dispatch key so a pdg run never reuses +// a pdg-off shard. The full worker-build + main-emit round-trip is exercised +// end-to-end by the U7 runPipelineFromRepo({ pdg: true }) integration test. + +describe('U5 — --pdg merges from CLI and .gitnexusrc', () => { + it('a CLI --pdg flag flows into the merged options', () => { + expect(mergeAnalyzeOptions({ pdg: true }, undefined).pdg).toBe(true); + }); + + it('a .gitnexusrc pdg value flows through when the CLI omits it', () => { + expect(mergeAnalyzeOptions({}, { pdg: true }).pdg).toBe(true); + }); + + it('the CLI flag wins over the file config', () => { + expect(mergeAnalyzeOptions({ pdg: true }, { pdg: false }).pdg).toBe(true); + }); + + it('absent everywhere ⇒ pdg is undefined (default off)', () => { + expect(mergeAnalyzeOptions({}, undefined).pdg).toBeUndefined(); + }); +}); + +describe('U5 — pdg perturbs the parse-cache / worker-dispatch key', () => { + // The chunk hash is what decides whether a chunk is re-dispatched to the + // workers (and thus whether the worker builds a CFG). Folding pdg in is the + // mechanism that makes the worker sink honor the flag across warm caches. + it('a pdg run computes a different chunk key than a pdg-off run', () => { + const entries = [{ filePath: 'a.ts', contentHash: 'h1' }]; + expect(computeChunkHash(entries, true)).not.toBe(computeChunkHash(entries, false)); + }); +}); diff --git a/gitnexus/test/unit/cfg/typescript-visitor.test.ts b/gitnexus/test/unit/cfg/typescript-visitor.test.ts new file mode 100644 index 000000000..8452d2d71 --- /dev/null +++ b/gitnexus/test/unit/cfg/typescript-visitor.test.ts @@ -0,0 +1,495 @@ +import { describe, it, expect } from 'vitest'; +import Parser from 'tree-sitter'; +import TypeScript from 'tree-sitter-typescript'; +import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js'; +import { + createTypeScriptCfgVisitor, + TS_FUNCTION_TYPES, +} from '../../../src/core/ingestion/cfg/visitors/typescript.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; + +// U2 — the TS/JS CfgVisitor, one hazard per test. Each fixture's distinctive +// statement text (markerWork(), handleErr(), cleanup(), …) lets us find the +// block for a region by text and assert the control-flow topology around it +// (R2, R10). The classic CFG hazards — loops/back-edges, switch fallthrough, +// try/finally post-domination, labeled jumps — are where builders break. + +const visitor = createTypeScriptCfgVisitor(); + +function parse(code: string): SyntaxNode { + const parser = new Parser(); + parser.setLanguage(TypeScript.typescript); + return parser.parse(code).rootNode; +} + +function collectFunctions(root: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + const stack = [root]; + while (stack.length) { + const n = stack.pop() as SyntaxNode; + if (TS_FUNCTION_TYPES.has(n.type)) out.push(n); + for (let i = n.namedChildCount - 1; i >= 0; i--) { + const c = n.namedChild(i); + if (c) stack.push(c); + } + } + return out; +} + +/** Build the CFG for the first (outermost-first by traversal) function in code. */ +function cfgOf(code: string, index = 0): FunctionCfg { + const fns = collectFunctions(parse(code)); + const fn = fns[index]; + if (!fn) throw new Error(`no function at index ${index}`); + const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts'); + if (!cfg) throw new Error('buildFunctionCfg returned undefined'); + return cfg; +} + +const block = (cfg: FunctionCfg, substr: string): number => { + const b = cfg.blocks.find((bl) => bl.text.includes(substr)); + if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`); + return b.index; +}; + +const edgeKinds = (cfg: FunctionCfg): Set => new Set(cfg.edges.map((e) => e.kind)); + +/** Does control reach `to` from `from` following edges? */ +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} + +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +describe('TS/JS CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT', () => { + const cfg = cfgOf(`function f() { a(); b(); c(); }`); + // a/b/c coalesce into one basic block + expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a();'); + expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty body: ENTRY → EXIT', () => { + const cfg = cfgOf(`function f() {}`); + expect(cfg.blocks).toHaveLength(2); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('expression-bodied arrow returns its expression', () => { + const cfg = cfgOf(`const f = (x: number) => x + 1;`); + const expr = block(cfg, 'x + 1'); + expect(cfg.edges).toContainEqual({ from: expr, to: cfg.exitIndex, kind: 'return' }); + }); +}); + +describe('TS/JS CfgVisitor — branching', () => { + it('if/else diamond emits cond-true + cond-false, both reach the join', () => { + const cfg = cfgOf(`function f(x) { if (x) { a(); } else { b(); } c(); }`); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const join = block(cfg, 'c();'); + expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true); + }); + + it('else-if chain: all three arms reachable and rejoin', () => { + const cfg = cfgOf(`function f(x) { + if (x === 1) { a(); } + else if (x === 2) { b(); } + else { c(); } + d(); + }`); + const join = block(cfg, 'd();'); + for (const arm of ['a();', 'b();', 'c();']) { + expect(reachable(cfg, block(cfg, arm))).toBe(true); + expect(reaches(cfg, block(cfg, arm), join)).toBe(true); + } + }); + + it('plain if (no else): condition reaches both the body and the join', () => { + const cfg = cfgOf(`function f(x) { if (x) { a(); } b(); }`); + const cond = block(cfg, 'x'); // condition block + const then = block(cfg, 'a();'); + const join = block(cfg, 'b();'); + expect(reaches(cfg, cond, then)).toBe(true); + expect(reaches(cfg, cond, join)).toBe(true); + expect(reaches(cfg, then, join)).toBe(true); + }); +}); + +describe('TS/JS CfgVisitor — loops', () => { + it('while loop has a back-edge and an exit', () => { + const cfg = cfgOf(`function f(x) { while (x > 0) { step(); } done(); }`); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + const body = block(cfg, 'step();'); + const header = block(cfg, 'x > 0'); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + }); + + it('do-while runs the body before testing and loops back', () => { + const cfg = cfgOf(`function f(x) { do { step(); } while (x > 0); done(); }`); + const body = block(cfg, 'step();'); + expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + expect(reaches(cfg, body, block(cfg, 'done();'))).toBe(true); + }); + + it('C-style for: init once, condition header, back-edge through increment', () => { + const cfg = cfgOf(`function f() { for (let i = 0; i < n; i++) { step(); } done(); }`); + const init = block(cfg, 'let i = 0'); + const incr = block(cfg, 'i++'); + const body = block(cfg, 'step();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: init, kind: 'seq' }); + expect(reaches(cfg, body, incr)).toBe(true); // body → increment + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + expect(reaches(cfg, incr, block(cfg, 'done();'))).toBe(true); + }); + + it('for-of loop builds a header/back-edge/exit', () => { + const cfg = cfgOf(`function f(xs) { for (const x of xs) { use(x); } done(); }`); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + expect(reaches(cfg, block(cfg, 'use(x)'), block(cfg, 'done();'))).toBe(true); + }); + + it('for-in loop builds a header/back-edge/exit', () => { + const cfg = cfgOf(`function f(o) { for (const k in o) { use(k); } done(); }`); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + expect(reaches(cfg, block(cfg, 'use(k)'), block(cfg, 'done();'))).toBe(true); + }); + + it('for without increment: body carries the loop-back, no phantom header self-loop (#2099 F5)', () => { + const cfg = cfgOf(`function f() { for (let i = 0; i < 3;) { i += 1; } done(); }`); + const header = block(cfg, 'i < 3'); + const body = block(cfg, 'i += 1'); + // The ONLY loop-back is the real back-edge body→header; a header→header + // self-loop would model a path that re-tests without running the body. + expect(cfg.edges.filter((e) => e.kind === 'loop-back')).toEqual([ + { from: body, to: header, kind: 'loop-back' }, + ]); + expect(cfg.edges.some((e) => e.from === header && e.to === header)).toBe(false); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); + }); + + it('for(;;) with conditional break: loop-back on the body, break reaches the join (#2099 F5)', () => { + const cfg = cfgOf(`function f() { for (;;) { if (x) break; work(); } done(); }`); + const work = block(cfg, 'work()'); + const loopBacks = cfg.edges.filter((e) => e.kind === 'loop-back'); + expect(loopBacks).toEqual([expect.objectContaining({ from: work })]); + const header = loopBacks[0].to; + expect(cfg.edges.some((e) => e.from === header && e.to === header)).toBe(false); + expect(edgeKinds(cfg).has('break')).toBe(true); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); + }); + + it('for with increment keeps seq-to-increment and loop-back on the increment (F5 regression guard)', () => { + const cfg = cfgOf(`function f() { for (let i = 0; i < 3; i++) { work(); } done(); }`); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'work()'), + to: block(cfg, 'i++'), + kind: 'seq', + }); + expect(cfg.edges).toContainEqual({ + from: block(cfg, 'i++'), + to: block(cfg, 'i < 3'), + kind: 'loop-back', + }); + }); + + it('empty body without increment keeps the genuine header self-loop', () => { + const cfg = cfgOf(`function f() { for (let i = 0; i < 3;) {} done(); }`); + const header = block(cfg, 'i < 3'); + expect(cfg.edges).toContainEqual({ from: header, to: header, kind: 'loop-back' }); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); + }); +}); + +describe('TS/JS CfgVisitor — switch', () => { + it('break-terminated cases dispatch to the exit, no fallthrough', () => { + const cfg = cfgOf(`function f(x) { + switch (x) { + case 1: one(); break; + case 2: two(); break; + default: other(); + } + after(); + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + const after = block(cfg, 'after();'); + expect(reaches(cfg, block(cfg, 'one();'), after)).toBe(true); + expect(reaches(cfg, block(cfg, 'two();'), after)).toBe(true); + // case 1 does NOT fall into case 2 (break severs it) + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false); + }); + + it('fallthrough: a case without break flows into the next case', () => { + const cfg = cfgOf(`function f(x) { + switch (x) { + case 1: one(); + case 2: two(); break; + } + after(); + }`); + expect(edgeKinds(cfg).has('fallthrough')).toBe(true); + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(true); + }); +}); + +describe('TS/JS CfgVisitor — try/catch/finally (R10)', () => { + it('normal completion AND a throw both flow through finally; finally reaches the post-try block', () => { + const cfg = cfgOf(`function f() { + try { + work(); + risky(); + } catch (e) { + handleErr(); + } finally { + cleanup(); + } + afterTry(); + }`); + const fin = block(cfg, 'cleanup();'); + const after = block(cfg, 'afterTry();'); + const work = block(cfg, 'work();'); + const handler = block(cfg, 'handleErr();'); + + expect(edgeKinds(cfg).has('throw')).toBe(true); + // normal path: try body → finally + expect(reaches(cfg, work, fin)).toBe(true); + // exceptional path: try body → catch → finally + expect(reaches(cfg, work, handler)).toBe(true); + expect(reaches(cfg, handler, fin)).toBe(true); + // finally post-dominates and reaches the continuation + expect(reaches(cfg, fin, after)).toBe(true); + }); + + it('try/finally with no catch: a throw still flows through finally', () => { + const cfg = cfgOf(`function f() { + try { risky(); } finally { cleanup(); } + afterTry(); + }`); + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true); + expect(reaches(cfg, fin, block(cfg, 'afterTry();'))).toBe(true); + }); + + it('an INTERIOR block of a branched try body reaches the handler (not just the body entry)', () => { + // Regression guard: the exceptional edge must cover every protected-region + // block, else a throw from inside a branch is invisible to the catch (a + // taint false-negative into `catch` for the downstream PDG analysis). + const cfg = cfgOf(`function f(x) { + try { + guardEntry(); + if (x) { deep(); } + } catch (e) { handler(e); } + }`); + const handler = block(cfg, 'handler(e);'); + expect(reaches(cfg, block(cfg, 'deep();'), handler)).toBe(true); // interior → handler + expect(reaches(cfg, block(cfg, 'guardEntry();'), handler)).toBe(true); + }); + + // #2099 F2 — an empty `catch {}` still CATCHES. The synthesized catch block + // has empty text, so locate it as the target of a throw-kind edge. + const throwTargets = (cfg: FunctionCfg): Set => + new Set(cfg.edges.filter((e) => e.kind === 'throw').map((e) => e.to)); + + it('empty catch {} swallows: throw lands in the catch, after-code reachable, no escape to EXIT (#2099 F2)', () => { + const cfg = cfgOf(`function f() { try { throw new Error('x'); } catch {} after(); }`); + const targets = throwTargets(cfg); + expect(targets.has(cfg.exitIndex)).toBe(false); // swallowed — never escapes + expect(targets.size).toBe(1); + const synth = [...targets][0]; + expect(cfg.blocks[synth].text).toBe(''); + expect(cfg.blocks[synth].kind).toBe('normal'); + expect(reaches(cfg, synth, block(cfg, 'after();'))).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('empty catch (e) {} with a binding behaves the same as catch {}', () => { + const cfg = cfgOf(`function f() { try { throw new Error('x'); } catch (e) {} after(); }`); + expect(throwTargets(cfg).has(cfg.exitIndex)).toBe(false); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('comment-only catch body counts as empty (comments are filtered)', () => { + const cfg = cfgOf( + `function f() { try { throw new Error('x'); } catch { /* ignore */ } after(); }`, + ); + expect(throwTargets(cfg).has(cfg.exitIndex)).toBe(false); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('empty catch + finally: catch flows into finally, no spurious re-propagation past it', () => { + const cfg = cfgOf(`function f() { + try { throw new Error('x'); } catch {} finally { fin(); } + after(); + }`); + const fin = block(cfg, 'fin();'); + // The swallowing catch exists, so the no-catch re-propagation gate must + // not fire: finally's exit goes to the continuation, never throw→EXIT. + expect( + cfg.edges.some((e) => e.from === fin && e.to === cfg.exitIndex && e.kind === 'throw'), + ).toBe(false); + const synth = [...throwTargets(cfg)].filter((t) => t !== fin); + expect(synth.length).toBeGreaterThan(0); + expect(reaches(cfg, synth[0], fin)).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('non-empty catch is unchanged by the empty-catch synthesis (F2 regression guard)', () => { + const cfg = cfgOf(`function f() { try { a(); } catch (e) { h(); } after(); }`); + expect(throwTargets(cfg).has(block(cfg, 'h();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'h();'), block(cfg, 'after();'))).toBe(true); + }); + + it('empty try + empty catch does not crash; after-code reachable from ENTRY', () => { + const cfg = cfgOf(`function f() { try {} catch {} after(); }`); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); +}); + +describe('TS/JS CfgVisitor — non-local jumps (R10)', () => { + it('early return wires to EXIT and ends its block', () => { + const cfg = cfgOf(`function f(x) { if (x) { return; } tail(); }`); + const ret = block(cfg, 'return;'); + expect(cfg.edges).toContainEqual({ from: ret, to: cfg.exitIndex, kind: 'return' }); + }); + + it('labeled break resolves to the outer loop exit, not the inner loop', () => { + const cfg = cfgOf(`function f(xs, ys) { + outer: for (const x of xs) { + for (const y of ys) { + if (x === y) { break outer; } + inner(); + } + afterInner(); + } + done(); + }`); + expect(edgeKinds(cfg).has('break')).toBe(true); + const brk = block(cfg, 'break outer;'); + const done = block(cfg, 'done();'); + // break outer escapes BOTH loops → reaches the post-loop block + expect(reaches(cfg, brk, done)).toBe(true); + // and does NOT route back through afterInner() (that's the inner loop's normal exit) + expect(reaches(cfg, brk, block(cfg, 'afterInner();'))).toBe(false); + }); + + it('labeled continue resolves to the labeled loop header', () => { + const cfg = cfgOf(`function f(xs, ys) { + outer: for (const x of xs) { + for (const y of ys) { + if (x === y) { continue outer; } + inner(); + } + } + }`); + expect(edgeKinds(cfg).has('continue')).toBe(true); + const cont = block(cfg, 'continue outer;'); + const outerHeader = block(cfg, 'x … xs'); + expect( + cfg.edges.some((e) => e.from === cont && e.to === outerHeader && e.kind === 'continue'), + ).toBe(true); + }); + + it('an unresolved labeled jump (stacked outer label) routes to EXIT, not a dangling sink', () => { + // `break outer` can't resolve (the outer label is unmodeled in M1), but the + // block must still reach EXIT so the graph stays single-exit for the + // downstream post-dominator / PDG computation — never a stranded sink. + const cfg = cfgOf(`function f(xs, ys) { + outer: inner: for (const x of xs) { + for (const y of ys) { if (x === y) { break outer; } body(); } + } + }`); + const brk = block(cfg, 'break outer;'); + expect(edgeKinds(cfg).has('break')).toBe(true); + expect(reaches(cfg, brk, cfg.exitIndex)).toBe(true); // not stranded + }); + + it('a standalone throw (no enclosing try) wires to EXIT and ends its block', () => { + const cfg = cfgOf(`function f(x) { if (x) { throw new Error(); } done(); }`); + const thr = block(cfg, 'throw new Error();'); + expect(cfg.edges).toContainEqual({ from: thr, to: cfg.exitIndex, kind: 'throw' }); + // the throw terminates its block — control does not fall into done() + expect(reaches(cfg, thr, block(cfg, 'done();'))).toBe(false); + // done() is still reachable via the if's false branch + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); + }); + + it('code after an unconditional return is emitted but unreachable from ENTRY', () => { + const cfg = cfgOf(`function f() { first(); return 1; dead(); }`); + const dead = block(cfg, 'dead();'); + expect(reachable(cfg, dead)).toBe(false); // emitted, but no edge reaches it + expect(reachable(cfg, block(cfg, 'first();'))).toBe(true); + }); +}); + +describe('TS/JS CfgVisitor — function-type coverage', () => { + // TS_FUNCTION_TYPES spans more than function_declaration/arrow. Confirm the + // body-walk produces a well-formed CFG for async / generator / method bodies. + it('builds a CFG for async functions, generators, and class methods', () => { + const code = ` + async function af(x) { if (x) { await a(); } done(); } + function* gf(xs) { for (const x of xs) { yield x; } } + class C { m(x) { if (x) { p(); } else { q(); } } async am() { await z(); } } + `; + const fns = collectFunctions(parse(code)); + // af, gf, m, am — four CFG-bearing functions + const cfgs = fns.map((fn) => visitor.buildFunctionCfg(fn, 'ft.ts')).filter((c) => c); + expect(cfgs.length).toBe(4); + for (const cfg of cfgs) { + expect(cfg).toBeDefined(); + if (!cfg) continue; + expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry'); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + } + }); +}); + +describe('TS/JS CfgVisitor — AC1: 10-function fixture', () => { + const TEN_FN = ` + function straight() { a(); b(); } + function withIf(x) { if (x) { a(); } else { b(); } } + function withElseIf(x) { if (x===1) { a(); } else if (x===2) { b(); } else { c(); } } + function withWhile(x) { while (x) { step(); } } + function withFor() { for (let i=0;i { + const fns = collectFunctions(parse(TEN_FN)).filter((f) => f.type === 'function_declaration'); + expect(fns).toHaveLength(10); + for (const fn of fns) { + const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts'); + expect(cfg).toBeDefined(); + if (!cfg) continue; + // ENTRY is index 0; indices are contiguous 0..n-1 + expect(cfg.blocks.map((b) => b.index)).toEqual(cfg.blocks.map((_, i) => i)); + expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry'); + expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit'); + // EXIT is reachable from ENTRY for every function + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + // No edge endpoint is out of range + for (const e of cfg.edges) { + expect(e.from).toBeGreaterThanOrEqual(0); + expect(e.to).toBeLessThan(cfg.blocks.length); + } + } + }); +}); diff --git a/gitnexus/test/unit/incremental-orchestration.test.ts b/gitnexus/test/unit/incremental-orchestration.test.ts index 3d0d244af..a00d67244 100644 --- a/gitnexus/test/unit/incremental-orchestration.test.ts +++ b/gitnexus/test/unit/incremental-orchestration.test.ts @@ -20,10 +20,8 @@ * (Windows LadybugDB handle release can lag; `cleanupTempDir` retries). */ -import { execSync } from 'child_process'; -import { writeFile, readFile, copyFile, mkdir } from 'fs/promises'; +import { writeFile, readFile } from 'fs/promises'; import path from 'path'; -import { fileURLToPath } from 'url'; import { describe, it, expect } from 'vitest'; import { getStoragePaths, @@ -32,43 +30,9 @@ import { INCREMENTAL_SCHEMA_VERSION, type RepoMeta, } from '../../src/storage/repo-manager.js'; -import { createTempDir } from '../helpers/test-db.js'; +import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js'; -const HERE = path.dirname(fileURLToPath(import.meta.url)); -const FIXTURE_SRC = path.resolve(HERE, '..', 'fixtures', 'mini-repo', 'src'); - -/** - * Copy the mini-repo fixture into a fresh git-initialized temp directory. - * Returns the temp handle so the caller owns cleanup. - */ -async function setupMiniRepo(): Promise<{ dbPath: string; cleanup: () => Promise }> { - const tmp = await createTempDir('gitnexus-incr-orch-'); - const dest = path.join(tmp.dbPath, 'src'); - await mkdir(dest, { recursive: true }); - // Copy mini-repo fixture files - const names = [ - 'index.ts', - 'handler.ts', - 'validator.ts', - 'formatter.ts', - 'middleware.ts', - 'logger.ts', - 'db.ts', - ]; - for (const n of names) { - await copyFile(path.join(FIXTURE_SRC, n), path.join(dest, n)); - } - execSync('git init', { cwd: tmp.dbPath, stdio: 'pipe' }); - execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false add -A', { - cwd: tmp.dbPath, - stdio: 'pipe', - }); - execSync('git -c user.name=test -c user.email=t@t -c commit.gpgsign=false commit -q -m initial', { - cwd: tmp.dbPath, - stdio: 'pipe', - }); - return tmp; -} +const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-incr-orch-'); describe('runFullAnalysis — incremental orchestration', () => { it('first run populates fileHashes + schemaVersion and clears incrementalInProgress on success', async () => { diff --git a/gitnexus/test/unit/pdg-mode-flip.test.ts b/gitnexus/test/unit/pdg-mode-flip.test.ts new file mode 100644 index 000000000..1000ef952 --- /dev/null +++ b/gitnexus/test/unit/pdg-mode-flip.test.ts @@ -0,0 +1,150 @@ +/** + * Integration coverage for the pdg-mode flip → forced-full-writeback wiring + * (#2099 F1). Sibling of incremental-orchestration.test.ts: real on-disk git + * repo, real LadybugDB, real `runFullAnalysis`. + * + * The P1 these tests pin: the incremental DB writeback persists only + * changed-file nodes, so before the fix a `--pdg` run against an + * already-indexed repo silently persisted ZERO BasicBlock rows + * (`Incremental: changed=0`), and a plain run after a `--pdg` index left + * zombie blocks. The primary assertion is a direct count over the BasicBlock + * table — `meta.stats.nodes` aggregates Community/Process rows that are + * re-derived nondeterministically every run, so it is only used as a + * secondary signal here, never with exact equality. + */ + +import { describe, it, expect } from 'vitest'; +import { getStoragePaths, loadMeta, saveMeta } from '../../src/storage/repo-manager.js'; +import { setupMiniRepo as setupSharedMiniRepo } from '../helpers/mini-repo.js'; + +const setupMiniRepo = () => setupSharedMiniRepo('gitnexus-pdg-flip-'); + +/** Direct count over the BasicBlock table — the primary truth signal. */ +async function countBasicBlocks(repoPath: string): Promise { + const adapter = await import('../../src/core/lbug/lbug-adapter.js'); + const { lbugPath } = getStoragePaths(repoPath); + await adapter.initLbug(lbugPath); + try { + const rows = (await adapter.executeQuery( + 'MATCH (n:BasicBlock) RETURN count(n) AS c', + )) as Array<{ c: number | bigint }>; + return Number(rows[0]?.c ?? 0); + } finally { + await adapter.closeLbug(); + } +} + +describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => { + it('off→on flip forces a full writeback that persists the CFG layer; on→off removes it', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const { storagePath } = getStoragePaths(repo.dbPath); + const logs: string[] = []; + const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) }; + + // 1. Plain index — no CFG layer, no stamp. + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb); + expect(await countBasicBlocks(repo.dbPath)).toBe(0); + expect((await loadMeta(storagePath))!.pdg).toBeUndefined(); + + // 2. The P1 trigger: --pdg with NO file changes. Pre-fix this hit the + // alreadyUpToDate fast path (or the incremental path with changed=0) + // and persisted nothing. The flip check must force a full rebuild. + logs.length = 0; + const flipOn = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb); + expect(flipOn.alreadyUpToDate).toBeUndefined(); + expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true); + expect(await countBasicBlocks(repo.dbPath)).toBeGreaterThan(0); + const stamped = await loadMeta(storagePath); + expect(stamped!.pdg).toEqual({ maxFunctionLines: 2000, maxEdgesPerFunction: 5000 }); + expect(stamped!.incrementalInProgress).toBeUndefined(); // cleared on success + + // 3. Steady state: a second identical --pdg run takes the fast path — + // the flip check must compare equal (KTD5 default resolution). + logs.length = 0; + const steady = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb); + expect(steady.alreadyUpToDate).toBe(true); + expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(false); + + // 4. Flip back: a plain run must fully remove the CFG layer (no + // zombie BasicBlocks) and clear the stamp. + logs.length = 0; + const flipOff = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb); + expect(flipOff.alreadyUpToDate).toBeUndefined(); + expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true); + expect(await countBasicBlocks(repo.dbPath)).toBe(0); + expect((await loadMeta(storagePath))!.pdg).toBeUndefined(); + } finally { + await repo.cleanup(); + } + }, 600_000); + + it('a cap change while pdg stays on forces a rebuild; matching modes keep incremental eligibility', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const { storagePath } = getStoragePaths(repo.dbPath); + const logs: string[] = []; + const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) }; + + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb); + const blocks = await countBasicBlocks(repo.dbPath); + expect(blocks).toBeGreaterThan(0); + + // Cap change with no file changes → mismatch → full rebuild (the + // emit-time cap shapes the persisted edge set; meta must re-stamp). + logs.length = 0; + const capChange = await runFullAnalysis( + repo.dbPath, + { skipAgentsMd: true, pdg: true, pdgMaxEdgesPerFunction: 1 }, + cb, + ); + expect(capChange.alreadyUpToDate).toBeUndefined(); + expect(logs.some((m) => m.includes('different caps'))).toBe(true); + expect((await loadMeta(storagePath))!.pdg).toEqual({ + maxFunctionLines: 2000, + maxEdgesPerFunction: 1, + }); + // The CFG layer survives a rebuild under a tighter edge cap (blocks are + // never capped, only edges). + expect(await countBasicBlocks(repo.dbPath)).toBe(blocks); + } finally { + await repo.cleanup(); + } + }, 600_000); + + it('a dirty flag from a crashed full rebuild composes with the flip check: one rebuild, flag cleared', async () => { + const repo = await setupMiniRepo(); + try { + const { runFullAnalysis } = await import('../../src/core/run-analyze.js'); + const { storagePath } = getStoragePaths(repo.dbPath); + const logs: string[] = []; + const cb = { onProgress: () => {}, onLog: (m: string) => logs.push(m) }; + + await runFullAnalysis(repo.dbPath, { skipAgentsMd: true, pdg: true }, cb); + + // Simulate a full rebuild that died between the pre-wipe dirty-flag + // write (KTD2b: toWriteCount 0 sentinel) and the end-of-run saveMeta. + const meta = (await loadMeta(storagePath))!; + await saveMeta(storagePath, { + ...meta, + incrementalInProgress: { startedAt: Date.now(), toWriteCount: 0 }, + }); + + // Next plain run: crash recovery fires (force), the flip ALSO logs its + // notice (decoupled from the force gate), and exactly one rebuild runs. + logs.length = 0; + const recovered = await runFullAnalysis(repo.dbPath, { skipAgentsMd: true }, cb); + expect(recovered.alreadyUpToDate).toBeUndefined(); + expect(logs.some((m) => m.includes('did not complete cleanly'))).toBe(true); + expect(logs.some((m) => m.includes('pdg mode changed'))).toBe(true); + const after = await loadMeta(storagePath); + expect(after!.incrementalInProgress).toBeUndefined(); + expect(after!.pdg).toBeUndefined(); + expect(await countBasicBlocks(repo.dbPath)).toBe(0); + } finally { + await repo.cleanup(); + } + }, 600_000); +}); diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index 47cb09b81..ba80bde8f 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -328,3 +328,56 @@ describe('deriveEmbeddingCap', () => { expect(deriveEmbeddingCap(15_000, 10_000).skipForCap).toBe(true); }); }); + +describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => { + const DEFAULTS = { maxFunctionLines: 2000, maxEdgesPerFunction: 5000 }; + + it('resolvePdgConfig: pdg-off run resolves to undefined (the meta field is omitted)', async () => { + const { resolvePdgConfig } = await import('../../src/core/run-analyze.js'); + expect(resolvePdgConfig({})).toBeUndefined(); + expect(resolvePdgConfig({ pdg: false })).toBeUndefined(); + }); + + it('resolvePdgConfig: caps resolve to their defaults; 0 = unlimited is preserved', async () => { + const { resolvePdgConfig } = await import('../../src/core/run-analyze.js'); + expect(resolvePdgConfig({ pdg: true })).toEqual(DEFAULTS); + expect( + resolvePdgConfig({ pdg: true, pdgMaxFunctionLines: 0, pdgMaxEdgesPerFunction: 0 }), + ).toEqual({ maxFunctionLines: 0, maxEdgesPerFunction: 0 }); + }); + + it('legacy meta (no recorded stamp) + plain run → no mismatch', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + expect(pdgModeMismatch(undefined, {})).toBe(false); + }); + + it('legacy meta + --pdg run → mismatch (the P1 trigger)', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + expect(pdgModeMismatch(undefined, { pdg: true })).toBe(true); + }); + + it('recorded stamp + plain run → mismatch (zombie-cleanup direction)', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + expect(pdgModeMismatch(DEFAULTS, {})).toBe(true); + }); + + it('explicit defaults compare equal to absent caps (KTD5 normalization)', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + expect(pdgModeMismatch(DEFAULTS, { pdg: true })).toBe(false); + expect( + pdgModeMismatch(DEFAULTS, { + pdg: true, + pdgMaxFunctionLines: 2000, + pdgMaxEdgesPerFunction: 5000, + }), + ).toBe(false); + }); + + it('a cap change while pdg stays on → mismatch (persisted edges differ)', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxEdgesPerFunction: 1 })).toBe(true); + expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 500 })).toBe(true); + // 0 = unlimited differs from the 2000-line default, too. + expect(pdgModeMismatch(DEFAULTS, { pdg: true, pdgMaxFunctionLines: 0 })).toBe(true); + }); +});