diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index 246baa1d4..0b5b6d130 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -27,6 +27,7 @@ import { isExitReachableFromAllBlocks, NO_IPDOM, } from './post-dominators.js'; +import { augmentForPostDom } from './synthetic-escape.js'; import type { BindingEntry, FunctionCfg } from './types.js'; /** @@ -511,12 +512,24 @@ export function emitFileCdg( for (const cfg of cfgs) { const { filePath, functionStartLine, functionStartColumn } = cfg; + // Synthetic-escape pass (#2197 U1): restore EXIT reverse-reachability for a + // genuine exit-unreachable CYCLE (an unconditional `goto`-cycle / infinite + // loop) so the post-dom / CDG pass runs instead of being withheld. A no-op + // (returns `cfg` unchanged) for terminating functions and properly-escaped + // loops — those stay byte-identical. The synthetic edges are ANALYSIS-ONLY: + // they live on the returned shallow clone, never on the persisted `cfg`, so + // CFG / REACHING_DEF and the byte-identical-off golden are unaffected. Both + // the gate below AND the post-dom / CDG passes must see the augmented view + // (KTD7 — the Ferrante walk re-reads `cfg.edges`). + const view = augmentForPostDom(cfg); // Sound post-dominance requires EXIT reachable from every entry-reachable - // block (#2188 review). A CFG that violates it — a future visitor's - // multi-terminal / non-terminating shape — would yield a CDG that both - // drops real and invents spurious dependences, so skip CDG for it. CFG and - // REACHING_DEF (emitted elsewhere, independent of post-dominance) are kept. - if (!isExitReachableFromAllBlocks(cfg)) { + // block (#2188 review). The synthetic-escape pass recovers genuine cycles; + // anything STILL unreachable after it is a residual non-cycle anomaly (a + // dangling/dead-end block, a branch-less trapping spin, or a construction + // error) — NOT something we bridge (that would mask the bug). Skip CDG for + // it and surface the skip. CFG and REACHING_DEF (emitted elsewhere, + // independent of post-dominance) are kept. + if (!isExitReachableFromAllBlocks(view)) { result.skippedUnsoundFunctions++; onWarn?.( `[cdg] ${filePath}:${functionStartLine}: EXIT not reachable from all ` + @@ -525,13 +538,16 @@ export function emitFileCdg( continue; } // Compute the post-dom tree once and feed it to the control-dependence - // pass (avoids recomputing it) and to the optional POST_DOMINATE emit. - const tree = computePostDominators(cfg); + // pass (avoids recomputing it) and to the optional POST_DOMINATE emit. The + // CDG edges reference BLOCK INDICES, which are identical in `view` and `cfg` + // (the augmentation only appends edges), so persisting them keyed off the + // original block ids is correct. + const tree = computePostDominators(view); // Bound the pre-dedup materialization (heap parity with REACHING_DEF). The // fixed ceiling is a catastrophe backstop; the per-function edge cap below // remains the reporting authority. A ceiling hit is surfaced, not silent. const { edges: cdgEdges, truncated } = computeControlDependence( - cfg, + view, tree, DEFAULT_PDG_MAX_CDG_MATERIALIZATION_PER_FUNCTION, ); diff --git a/gitnexus/src/core/ingestion/cfg/synthetic-escape.ts b/gitnexus/src/core/ingestion/cfg/synthetic-escape.ts new file mode 100644 index 000000000..3f69e1a70 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/synthetic-escape.ts @@ -0,0 +1,335 @@ +/** + * Synthetic-escape pass for CDG soundness (#2197 U1). + * + * THE PROBLEM. Control dependence is computed over the post-dominator tree + * (control-dependence.ts), which is only sound when EXIT is reverse-reachable + * from every entry-reachable block (post-dominators.ts § + * {@link isExitReachableFromAllBlocks}). Loop visitors keep that invariant by + * giving every loop a structural `header → loopExit` `cond-false` edge — so an + * ordinary `while`/`for` always has a path to EXIT. The `goto` handlers + * (C/C++/C#/Go), however, wire an UNCONDITIONAL back-edge as plain `seq` with no + * such escape: + * + * void handler(int a){ start: if (a > 0) { work(); } goto start; } + * + * Here every body block sits in a trapping cycle (`start … goto start`) with no + * path to EXIT, so EXIT is non-reverse-reachable and {@link emitFileCdg} (the + * soundness gate) WITHHOLDS all control dependence for the whole function — + * silent CDG coverage loss for an entire (common) class of functions. + * + * THE FIX (nontermination-sensitive control dependence — Ranganath et al., + * TOPLAS 2007). For a genuinely exit-unreachable *cycle* (an infinite loop), add + * an ANALYSIS-ONLY virtual escape edge from the cycle's controlling branch to + * EXIT, making the post-dom tree well-defined again. The synthetic edge is inert + * in the Ferrante walk (EXIT post-dominates its source, so the post-dom guard + * skips it) — it only restores reverse-reachability so the REAL control points + * inside the loop get their dependences. + * + * ANALYSIS-ONLY (load-bearing — KTD7). The pass NEVER mutates the input. The + * persisted CFG / REACHING_DEF graph and the byte-identical-off golden depend on + * `cfg.edges` staying faithful, so the augmentation lives on a shallow-cloned + * {@link FunctionCfg} whose `edges` is `[...cfg.edges, ...synthetic]`. Because + * both {@link computePostDominators} AND {@link computeControlDependence} + * (its Ferrante walk + `buildArmSenses`) re-read `cfg.edges` directly, the + * augmented view must be passed to BOTH — feeding only an augmented post-dom + * tree would leave the walk on the un-augmented edges (KTD7). + * + * PURE AND DETERMINISTIC (mirrors post-dominators.ts / reaching-defs.ts). The + * SCC routine sorts every adjacency list and emits SCCs root-deterministically, + * so the chosen representative — hence the augmented edge set and any downstream + * snapshot — is identical across runs. + * + * WHICH SCCs ARE BRIDGED (KTD2 / KTD6, and the anti-masking guarantee R2). The + * decision is gated on the WHOLE entry-reachable trapped region (the union of + * the entry-reachable blocks that cannot reach EXIT): the pass bridges only when + * that region contains at least one *control point* — a block with ≥2 successors + * (a branch terminator). A region with a control point is a real, recoverable + * loop (a `goto`-cycle always carries the `if` predicate from its guard); a + * region with NO control point is a branch-less infinite spin that carries no + * control dependence to recover AND is indistinguishable from a genuine + * CFG-construction anomaly (e.g. a disconnected EXIT block), so it is + * deliberately LEFT UNBRIDGED — the existing soundness gate then skips the + * function and surfaces the skip (R2 / R3). In practice a branch-less trapping + * region never comes from a real loop visitor (loops emit the structural escape + * edge) — it signals a construction error, exactly what we must not paper over. + * + * When the region is bridged, EACH exit-less SCC gets one synthetic escape edge + * from its *controlling representative*: the entry-reachable member with a branch + * terminator (≥2 successors), highest out-degree, lowest-index tie-break. That + * branch is the predicate deciding stay-in-loop vs. leave, the faithful escape + * representative; attaching the escape anywhere else invents or drops CDG edges + * while still passing the AC2 post-dominance property test, so the choice is + * pinned by an exact-edge-set test, not `CDG>0`. When an exit-less SCC has NO + * internal branch (e.g. the body of an irreducible loop whose control point sits + * OUTSIDE the cycle), its escape attaches to the lowest-index member — the + * choice is semantically immaterial (the SCC has no internal control point so it + * contributes no internal CDG), and a deterministic index keeps snapshots + * stable. This per-SCC bridging restores reverse-reachability for the whole + * region in one batch, then the gate re-checks (KTD2). + * + * GRANULARITY OF A MIXED cycle + dead-end FUNCTION. {@link emitFileCdg} is + * all-or-nothing per function: it computes CDG only when EXIT is reverse- + * reachable from EVERY entry-reachable block. So if a function contains a + * recoverable goto-cycle AND a *separate* residual block that is still + * exit-unreachable after all escapes (a dangling/dead-end block not in any + * bridgeable cycle), the pass restores the cycle but the residual block keeps + * EXIT non-reverse-reachable → the WHOLE function is still skipped and surfaced. + * We do NOT bridge the residual (that would mask the construction error), and we + * do NOT emit partial per-cycle CDG (the emit layer has no partial mode). This + * is the documented, intentional trade-off: recover the common goto-cycle case; + * surface anything with a genuine residual anomaly rather than guess. + */ +import { + isExitReachableFromAllBlocks, + type PostDomTree, +} from './post-dominators.js'; +import type { CfgEdgeData, FunctionCfg } from './types.js'; + +/** + * The synthetic escape edge kind. Reuses the existing `cond-false` kind (the + * same kind every loop's structural `header → loopExit` escape carries — see the + * module doc), so the augmented view is structurally indistinguishable from a + * normally-escaped loop and `buildArmSenses`/`labelFor` treat it identically. + * The edge is analysis-only and never persisted. + */ +const SYNTHETIC_ESCAPE_KIND: CfgEdgeData['kind'] = 'cond-false'; + +/** Forward / reverse reachability over a CFG's in-range edges. */ +interface Reachability { + /** `fromEntry[b]` — block `b` is forward-reachable from ENTRY. */ + readonly fromEntry: Uint8Array; + /** `canReachExit[b]` — block `b` can reach EXIT (reverse-reachable from it). */ + readonly canReachExit: Uint8Array; + /** Forward adjacency (sorted, in-range). */ + readonly succ: readonly number[][]; +} + +function reach(start: number, adj: readonly number[][], n: number): Uint8Array { + const seen = new Uint8Array(n); + if (start < 0 || start >= n) return seen; + const stack = [start]; + seen[start] = 1; + while (stack.length > 0) { + const b = stack.pop() as number; + for (const next of adj[b]) { + if (!seen[next]) { + seen[next] = 1; + stack.push(next); + } + } + } + return seen; +} + +function computeReachability(cfg: FunctionCfg): Reachability { + const n = cfg.blocks.length; + const succ: number[][] = Array.from({ length: n }, () => []); + const pred: number[][] = Array.from({ length: n }, () => []); + for (const e of cfg.edges) { + if (e.from < 0 || e.from >= n || e.to < 0 || e.to >= n) continue; + succ[e.from].push(e.to); + pred[e.to].push(e.from); + } + // Sorted adjacency — determinism (mirrors post-dominators.ts). + for (const l of succ) l.sort((a, b) => a - b); + return { + fromEntry: reach(cfg.entryIndex, succ, n), + canReachExit: reach(cfg.exitIndex, pred, n), + succ, + }; +} + +/** + * Strongly-connected components of a CFG via an ITERATIVE Tarjan over the + * forward edges. Pure and deterministic: nodes are visited in ascending index + * and every successor list is iterated in sorted order, so the component + * partition (and the per-component member order) is identical across runs. + * + * Returns `compOf[b]` = the component id of block `b`, plus `members[c]` = the + * (ascending-index) members of component `c`. Component ids are assigned in + * Tarjan completion order (a reverse-topological order over the condensation), + * which is deterministic but not relied upon — callers key on `compOf`. + */ +export interface SccResult { + readonly compOf: readonly number[]; + readonly members: readonly (readonly number[])[]; +} + +export function computeScc(succ: readonly number[][], n: number): SccResult { + const compOf = new Array(n).fill(-1); + const members: number[][] = []; + + const index = new Array(n).fill(-1); + const lowlink = new Array(n).fill(0); + const onStack = new Uint8Array(n); + const tarjanStack: number[] = []; + let nextIndex = 0; + + // Explicit work stack: each frame tracks the node and how far through its + // (sorted) successor list we have iterated, so recursion depth never blows the + // JS stack on a large per-function CFG. + for (let root = 0; root < n; root++) { + if (index[root] !== -1) continue; + const work: { node: number; childIdx: number }[] = [{ node: root, childIdx: 0 }]; + while (work.length > 0) { + const frame = work[work.length - 1]; + const v = frame.node; + if (frame.childIdx === 0) { + // First visit to v. + index[v] = nextIndex; + lowlink[v] = nextIndex; + nextIndex += 1; + tarjanStack.push(v); + onStack[v] = 1; + } + const succs = succ[v]; + if (frame.childIdx < succs.length) { + const w = succs[frame.childIdx]; + frame.childIdx += 1; + if (index[w] === -1) { + // Descend into the unvisited child; resume v afterwards. + work.push({ node: w, childIdx: 0 }); + } else if (onStack[w]) { + if (index[w] < lowlink[v]) lowlink[v] = index[w]; + } + continue; + } + // All successors of v processed: propagate lowlink to the parent, and if v + // is a component root, pop its component off the Tarjan stack. + if (lowlink[v] === index[v]) { + const comp: number[] = []; + for (;;) { + const w = tarjanStack.pop() as number; + onStack[w] = 0; + comp.push(w); + if (w === v) break; + } + comp.sort((a, b) => a - b); // ascending member order — determinism + const id = members.length; + for (const w of comp) compOf[w] = id; + members.push(comp); + } + work.pop(); + if (work.length > 0) { + const parent = work[work.length - 1].node; + if (lowlink[v] < lowlink[parent]) lowlink[parent] = lowlink[v]; + } + } + } + + return { compOf, members }; +} + +/** + * Restore EXIT reverse-reachability for genuine exit-unreachable cycles so the + * post-dom / CDG pass runs on a well-defined tree, WITHOUT masking construction + * errors or perturbing sound functions. See the module doc for the full + * contract. + * + * Returns the input `cfg` UNCHANGED (referential no-op) when EXIT is already + * reverse-reachable from every entry-reachable block — terminating functions and + * properly-escaped loops are byte-identical (zero synthetic edges). Otherwise + * returns a SHALLOW-CLONED {@link FunctionCfg} whose `edges` is the original + * edges followed by the synthetic escapes; the input's `edges` is never mutated. + * + * Pass the returned view to BOTH {@link computePostDominators} and + * {@link computeControlDependence} (KTD7). + */ +export function augmentForPostDom(cfg: FunctionCfg): FunctionCfg { + const n = cfg.blocks.length; + const { entryIndex, exitIndex } = cfg; + if (n === 0 || entryIndex < 0 || entryIndex >= n || exitIndex < 0 || exitIndex >= n) { + return cfg; // degenerate — leave to the existing gate + } + + const { fromEntry, canReachExit, succ } = computeReachability(cfg); + + // No-op fast path: every entry-reachable block already reaches EXIT. + let allReach = true; + for (let b = 0; b < n; b++) { + if (fromEntry[b] && !canReachExit[b]) { + allReach = false; + break; + } + } + if (allReach) return cfg; + + // Anti-masking gate (R2): only bridge when the entry-reachable TRAPPED REGION + // (the union of entry-reachable blocks that cannot reach EXIT) holds at least + // one control point — a block with ≥2 successors. A branch-less trapped region + // is a degenerate spin / construction anomaly we refuse to mask; the existing + // soundness gate skips it and surfaces the skip. A real `goto`-cycle always + // carries its guard's `if`, so it is recovered; a disconnected/dangling EXIT + // (no branch anywhere in the trap) is left to skip. See the module doc. + let regionHasControlPoint = false; + for (let b = 0; b < n; b++) { + if (fromEntry[b] && !canReachExit[b] && succ[b].length >= 2) { + regionHasControlPoint = true; + break; + } + } + if (!regionHasControlPoint) return cfg; + + // Condense into SCCs over the real edges. + const { members } = computeScc(succ, n); + + // Bridge EACH exit-less SCC (a trapping cycle: no member can reach EXIT, so + // `canReachExit` is false for the whole SCC). `canReachExit` already encodes + // the transitive closure, so a single member's flag answers it for the SCC. + const synthetic: CfgEdgeData[] = []; + for (const comp of members) { + if (comp.length === 0) continue; + const rep = comp[0]; // ascending-order members → comp[0] is the lowest index + if (canReachExit[rep]) continue; // SCC escapes to EXIT — nothing to bridge + // Only genuine CYCLES trap. A singleton SCC with no self-edge is an ordinary + // acyclic block (ENTRY / the spine) that is exit-unreachable only because it + // FEEDS a trap downstream; it gets its path to EXIT for free once the trap is + // bridged, so it is never bridged on its own. + const isCycle = + comp.length > 1 || cfg.edges.some((e) => e.from === rep && e.to === rep); + if (!isCycle) continue; + + // Controlling representative: the entry-reachable member with a branch + // terminator (≥2 successors), highest out-degree, lowest-index tie-break. + // When the SCC has no internal branch (its control point sits outside, e.g. + // an irreducible loop body), fall back to the lowest-index member — the + // choice is immaterial (no internal control point ⇒ no internal CDG) and a + // deterministic index keeps snapshots stable (KTD6). + let controller = -1; + let bestOutDeg = 1; // require ≥2 to qualify as a branch + for (const b of comp) { + if (!fromEntry[b]) continue; + const outDeg = succ[b].length; + if (outDeg >= 2 && outDeg > bestOutDeg) { + bestOutDeg = outDeg; + controller = b; + } + } + if (controller === -1) { + // No internal branch — attach at the lowest entry-reachable member (or the + // lowest member if none is entry-reachable, a defensive fallback). + controller = comp.find((b) => fromEntry[b]) ?? rep; + } + + synthetic.push({ from: controller, to: exitIndex, kind: SYNTHETIC_ESCAPE_KIND }); + } + + if (synthetic.length === 0) return cfg; // nothing bridgeable — gate will skip + + // Shallow clone with the augmented edge set; the input's `edges` is untouched. + return { ...cfg, edges: [...cfg.edges, ...synthetic] }; +} + +/** + * Convenience: `true` iff {@link augmentForPostDom} returned a DIFFERENT object + * (i.e. at least one synthetic escape edge was added). Useful for tests + * asserting the no-op path. Reference equality is exact: the no-op path returns + * the input unchanged. + */ +export function wasAugmented(cfg: FunctionCfg, view: FunctionCfg): boolean { + return view !== cfg; +} + +// Re-export so callers can build the augmented view and gate it in one import. +export { isExitReachableFromAllBlocks }; +export type { PostDomTree }; diff --git a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts index 359171082..80c8eefa1 100644 --- a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts @@ -7,6 +7,9 @@ import { } from '../../../src/core/ingestion/cfg/visitors/c-cpp.js'; import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js'; import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; +import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { augmentForPostDom } from '../../../src/core/ingestion/cfg/synthetic-escape.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; // U2 — the C/C++ CfgVisitor, one hazard per test (KTD5: real-parser regression, // NOT snapshot-pinning). Each fixture's distinctive statement text (step(), @@ -219,6 +222,27 @@ describe('C CfgVisitor — goto / labels', () => { warn.mockRestore(); } }); + + // #2197 U1 — an UNCONDITIONAL goto-cycle traps EXIT (the `goto start` has no + // exit path), so without the synthetic-escape pass `emitFileCdg` would withhold + // ALL control dependence. After the pass the cycle is bridged and CDG is + // emitted. The conditional goto tests above already had an exit path (the + // if-false arm reaches `done()`), so they did NOT exercise this gap. + it('unconditional goto-cycle: bridged → EXIT reachable AND CDG emitted (C)', () => { + const cfg = c.cfgOf(`void handler(int a){ start: if(a>0){work();} goto start; }`); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); // trapped without the pass + const view = augmentForPostDom(cfg); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + expect(computeControlDependence(view).edges.length).toBeGreaterThan(0); + }); + + it('unconditional goto-cycle: bridged → EXIT reachable AND CDG emitted (C++)', () => { + const cfg = cpp.cfgOf(`void handler(int a){ start: if(a>0){work();} goto start; }`); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); + const view = augmentForPostDom(cfg); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + expect(computeControlDependence(view).edges.length).toBeGreaterThan(0); + }); }); describe('C CfgVisitor — def/use harvest', () => { diff --git a/gitnexus/test/unit/cfg/control-dependence.test.ts b/gitnexus/test/unit/cfg/control-dependence.test.ts index e307fb69b..3c3a29e9b 100644 --- a/gitnexus/test/unit/cfg/control-dependence.test.ts +++ b/gitnexus/test/unit/cfg/control-dependence.test.ts @@ -6,8 +6,10 @@ import { } from '../../../src/core/ingestion/cfg/control-dependence.js'; import { computePostDominators, + isExitReachableFromAllBlocks, postDominates, } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { augmentForPostDom } from '../../../src/core/ingestion/cfg/synthetic-escape.js'; import type { BasicBlockData, CfgEdgeData, @@ -76,8 +78,10 @@ function succsOf(cfg: FunctionCfg): number[][] { * post-dominates `b` iff every path from `b` to EXIT passes through `p`: * reflexive (`p === b`), else true exactly when EXIT is unreachable from `b` * once `p` is removed (AND `b` can reach EXIT at all). Defined only for the - * exit-reachable fixtures used below — the exit-unreachable case is the known - * unsound region (#2188 F2) and is deliberately excluded from the AC2 set. + * exit-reachable fixtures used below. A raw exit-unreachable cycle (#2188 F2) + * would be unsound, so the AC2 set now feeds the synthetic-escape pass's + * AUGMENTED view of every goto-cycle fixture (#2197 U1) — once bridged it IS + * exit-reachable and the Ferrante walk must equal this independent reference. */ function independentPostDom(cfg: FunctionCfg, succs: number[][], p: number, b: number): boolean { if (p === b) return true; @@ -255,10 +259,42 @@ describe('computeControlDependence — Ferrante §3.1.1', () => { [2, 1, 'loop-back'], [1, 3, 'cond-false'], ]), - // NOTE: the exit-unreachable case is deliberately NOT an AC2 fixture — its - // dependence set is unsound (#2188 F2), so asserting walk == independent - // reference would (correctly) fail. It has its own characterization test - // above that documents the degenerate behavior. + // Escaped `goto`-cycle (#2197 U1): after the synthetic-escape pass the + // exit-unreachable cycle is bridged and the dependence set becomes a SOUND + // over-approximation, so it now joins the AC2 set (the obsolete + // exit-unreachable exclusion is lifted — see the AUGMENTED-view note below). + // Repro shape: ENTRY=0, EXIT=1, b2=`(a>0)` predicate, b3=`work()`, + // b4=`goto start`; the `if` predicate (b2) is the only control point. + gotoCycle: augmentForPostDom( + mkCfg( + 5, + [ + [0, 2, 'seq'], + [2, 3, 'cond-true'], + [2, 4, 'seq'], + [3, 4, 'seq'], + [4, 2, 'seq'], + ], + { entry: 0, exit: 1 }, + ), + ), + // Spine before the goto label — ENTRY + straight-line stmts are in the + // exit-unreachable closure but must reach EXIT after the bridge. + gotoCycleSpine: augmentForPostDom( + mkCfg( + 7, + [ + [0, 2, 'seq'], + [2, 3, 'seq'], + [3, 4, 'seq'], + [4, 5, 'cond-true'], + [4, 6, 'seq'], + [5, 6, 'seq'], + [6, 4, 'seq'], + ], + { entry: 0, exit: 1 }, + ), + ), // nested if: outer branch (0) → inner branch (1) or outer-else (5); // inner branch → 2/3 → inner join (4); 4 and 5 → outer join (6, exit). nestedIf: mkCfg( @@ -286,6 +322,15 @@ describe('computeControlDependence — Ferrante §3.1.1', () => { ]), }; + it.each(Object.keys(fixtures))( + '%s: is exit-reachable from all blocks (the AC2 reference is well-defined)', + (name) => { + // Every AC2 fixture — including the AUGMENTED goto-cycle ones (#2197 U1) + // — must be exit-reachable, else the node-removal reference is undefined. + expect(isExitReachableFromAllBlocks(fixtures[name])).toBe(true); + }, + ); + it.each(Object.keys(fixtures))( '%s: tree-walk pair set equals the brute-force reference', (name) => { diff --git a/gitnexus/test/unit/cfg/csharp-visitor.test.ts b/gitnexus/test/unit/cfg/csharp-visitor.test.ts index 7cdc96405..020708245 100644 --- a/gitnexus/test/unit/cfg/csharp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/csharp-visitor.test.ts @@ -3,6 +3,9 @@ import { createRequire } from 'node:module'; import { createCsharpCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/csharp.js'; import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/types.js'; import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; +import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { augmentForPostDom } from '../../../src/core/ingestion/cfg/synthetic-escape.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; // U3 — the C# CfgVisitor, one hazard per test (KTD5: real-parser regression, // NOT snapshot-pinning). Each fixture's distinctive statement text (step(), @@ -319,6 +322,17 @@ describe('C# CfgVisitor — goto / labels', () => { expect(reaches(cfg, gotoB, label)).toBe(true); expect(reachable(cfg, block(cfg, 'work();'))).toBe(true); }); + + // #2197 U1 — an UNCONDITIONAL goto-cycle traps EXIT (the `goto start` has no + // exit path); the synthetic-escape pass bridges it so CDG is emitted instead + // of withheld. The conditional goto tests above already had an exit path. + it('unconditional goto-cycle: bridged → EXIT reachable AND CDG emitted', () => { + const cfg = cs.cfgOf(`class K { void handler(int a){ start: if(a>0){work();} goto start; } }`); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); // trapped without the pass + const view = augmentForPostDom(cfg); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + expect(computeControlDependence(view).edges.length).toBeGreaterThan(0); + }); }); describe('C# CfgVisitor — yield', () => { diff --git a/gitnexus/test/unit/cfg/go-visitor.test.ts b/gitnexus/test/unit/cfg/go-visitor.test.ts index 8b0ba715b..2795b5ba1 100644 --- a/gitnexus/test/unit/cfg/go-visitor.test.ts +++ b/gitnexus/test/unit/cfg/go-visitor.test.ts @@ -5,6 +5,7 @@ import type { FunctionCfg, SiteRecord } from '../../../src/core/ingestion/cfg/ty import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; +import { augmentForPostDom } from '../../../src/core/ingestion/cfg/synthetic-escape.js'; // U5 — the Go CfgVisitor, one hazard per test (KTD5: real-parser regression, // NOT snapshot-pinning). Each fixture's distinctive statement text (step(), @@ -342,6 +343,17 @@ describe('Go CfgVisitor — labeled break / continue / goto', () => { expect(reaches(cfg, gotoB, label)).toBe(true); expect(reachable(cfg, block(cfg, 'work()'))).toBe(true); }); + + // #2197 U1 — an UNCONDITIONAL goto-cycle traps EXIT (the `goto start` has no + // exit path); the synthetic-escape pass bridges it so CDG is emitted instead + // of withheld. The forward goto above had an exit path (it skips to `done()`). + it('unconditional goto-cycle: bridged → EXIT reachable AND CDG emitted', () => { + const cfg = go.cfgOf(pkg(`func handler(a int){ start: if a>0 { work() }\n goto start }`)); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); // trapped without the pass + const view = augmentForPostDom(cfg); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + expect(computeControlDependence(view).edges.length).toBeGreaterThan(0); + }); }); describe('Go CfgVisitor — go statement (spawned flow not followed inline)', () => { diff --git a/gitnexus/test/unit/cfg/synthetic-escape.test.ts b/gitnexus/test/unit/cfg/synthetic-escape.test.ts new file mode 100644 index 000000000..ee02e01c0 --- /dev/null +++ b/gitnexus/test/unit/cfg/synthetic-escape.test.ts @@ -0,0 +1,357 @@ +import { describe, it, expect } from 'vitest'; +import { + augmentForPostDom, + computeScc, + wasAugmented, +} from '../../../src/core/ingestion/cfg/synthetic-escape.js'; +import { + computePostDominators, + isExitReachableFromAllBlocks, +} from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; +import type { + BasicBlockData, + CfgEdgeData, + CfgEdgeKind, + FunctionCfg, +} from '../../../src/core/ingestion/cfg/types.js'; + +// #2197 U1 — the synthetic-escape pass for CDG soundness. Hand-built CFGs (no +// tree-sitter). The pass restores EXIT reverse-reachability for a genuine +// exit-unreachable CYCLE (an unconditional `goto`-cycle / infinite loop) so the +// post-dom / CDG pass runs, WITHOUT masking construction errors (a branch-less +// trapping spin / a dangling dead-end block stays skipped) or perturbing sound +// functions (a no-op referential identity for terminating fns + escaped loops). +// +// The exact-edge-set pin is load-bearing: a WRONG escape representative still +// yields `CDG>0` and still passes the AC2 post-dominance property test, so each +// regression asserts the EXACT control-dependence set, not merely a non-empty one. + +// ── hand-built CFG helper (edges carry a kind so CDG labels can be asserted) ── + +function mkCfg( + blockCount: number, + edges: [number, number, CfgEdgeKind?][], + opts: { entry?: number; exit?: number } = {}, +): FunctionCfg { + const entry = opts.entry ?? 0; + const exit = opts.exit ?? blockCount - 1; + const blocks: BasicBlockData[] = Array.from({ length: blockCount }, (_, i) => ({ + index: i, + startLine: i + 1, + endLine: i + 1, + text: '', + kind: i === entry ? 'entry' : i === exit ? 'exit' : 'normal', + })); + const cfgEdges: CfgEdgeData[] = edges.map(([from, to, kind]) => ({ + from, + to, + kind: kind ?? 'seq', + })); + return { + filePath: 't.ts', + functionStartLine: 1, + functionStartColumn: 0, + entryIndex: entry, + exitIndex: exit, + blocks, + edges: cfgEdges, + }; +} + +const cdgSet = (cfg: FunctionCfg): string[] => + computeControlDependence(cfg) + .edges.map((e) => `${e.controllerBlock}->${e.dependentBlock}:${e.label}`) + .sort(); + +const syntheticEdges = (cfg: FunctionCfg, view: FunctionCfg): string[] => + view === cfg ? [] : view.edges.slice(cfg.edges.length).map((e) => `${e.from}->${e.to}`); + +/** + * The repro CFG, identical across C / C++ / C# / Go (verified by probe): + * + * void handler(int a){ start: if (a > 0) { work(); } goto start; } + * + * ENTRY=0 EXIT=1 b2 = `(a>0)` predicate b3 = `work()` b4 = `goto start` + * 0→2 seq | 2→3 cond-true | 2→4 seq (false arm skips work) | 3→4 seq | 4→2 seq + * + * EXIT(1) has no predecessor — the back-edge 4→2 traps blocks 2,3,4 in a cycle + * and EXIT is non-reverse-reachable, so without the pass `emitFileCdg` withholds + * ALL control dependence for the whole function. + */ +const reproGotoCycle = (): FunctionCfg => + mkCfg( + 5, + [ + [0, 2, 'seq'], + [2, 3, 'cond-true'], + [2, 4, 'seq'], + [3, 4, 'seq'], + [4, 2, 'seq'], + ], + { entry: 0, exit: 1 }, + ); + +describe('computeScc — pure deterministic SCC routine', () => { + it('partitions a simple cycle and singletons; member lists are ascending', () => { + // 0 → 1 ⇄ 2 → 3 ; SCCs: {0}, {1,2}, {3} + const succ = [[1], [2], [1, 3], []]; + const { compOf, members } = computeScc(succ, 4); + expect(compOf[1]).toBe(compOf[2]); // 1 and 2 share a component + expect(compOf[0]).not.toBe(compOf[1]); + expect(compOf[3]).not.toBe(compOf[1]); + // the {1,2} component lists members ascending + const cyc = members[compOf[1]]; + expect([...cyc]).toEqual([1, 2]); + }); + + it('is deterministic across runs (sorted adjacency)', () => { + const succ = [[2, 1], [2], [1, 3], [4], []]; + const a = computeScc(succ, 5); + const b = computeScc(succ, 5); + expect(a.compOf).toEqual(b.compOf); + expect(a.members.map((m) => [...m])).toEqual(b.members.map((m) => [...m])); + }); + + it('handles a self-loop as a singleton component', () => { + const succ = [[1], [1], []]; // 1 self-loops + const { members } = computeScc(succ, 3); + expect(members.some((m) => m.length === 1 && m[0] === 1)).toBe(true); + }); +}); + +describe('augmentForPostDom — the goto-cycle repro (exact CDG edge set)', () => { + it('bridges the cycle so EXIT is reachable and the EXACT CDG set is emitted', () => { + const cfg = reproGotoCycle(); + // Before the pass: EXIT is unreachable from the trapped blocks. + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); + + const view = augmentForPostDom(cfg); + expect(wasAugmented(cfg, view)).toBe(true); + // ONE synthetic escape, from the loop's controlling predicate (block 2) to + // EXIT (block 1) — the `if (a>0)` branch is the only control point. + expect(syntheticEdges(cfg, view)).toEqual(['2->1']); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + + // EXACT source-faithful CDG set — NOT merely `CDG>0` (a wrong representative + // would still give a non-empty set). The `if` predicate (block 2) controls: + // work() (b3) on its TRUE arm; + // the goto (b4), reached on BOTH arms (2→3→4 and 2→4) → T and F; + // its OWN re-execution (b2), reached on both arms via the back-edge → T,F. + expect(cdgSet(view)).toEqual(['2->2:F', '2->2:T', '2->3:T', '2->4:F', '2->4:T']); + }); + + it('the controller is the branch predicate, not an arbitrary cycle member', () => { + const view = augmentForPostDom(reproGotoCycle()); + const synth = view.edges.slice(reproGotoCycle().edges.length); + expect(synth).toHaveLength(1); + // block 2 is the only block with ≥2 successors (the if-branch) — it must be + // the escape source, not b3/b4 (the straight-line body / goto). + expect(synth[0].from).toBe(2); + expect(synth[0].to).toBe(1); // EXIT + }); +}); + +describe('augmentForPostDom — no-op for sound functions (referential identity)', () => { + it('a terminating straight-line function is returned UNCHANGED (zero synthetic edges)', () => { + const cfg = mkCfg(3, [ + [0, 1, 'seq'], + [1, 2, 'seq'], + ]); + const view = augmentForPostDom(cfg); + expect(view).toBe(cfg); // referential no-op + expect(wasAugmented(cfg, view)).toBe(false); + }); + + it('an ordinary escaped while-loop is returned UNCHANGED', () => { + // 0(entry) → 1(header); 1 → 2(body, T); 2 → 1 (back); 1 → 3(exit, F escape) + const cfg = mkCfg( + 4, + [ + [0, 1, 'seq'], + [1, 2, 'cond-true'], + [2, 1, 'loop-back'], + [1, 3, 'cond-false'], + ], + { entry: 0, exit: 3 }, + ); + const view = augmentForPostDom(cfg); + expect(view).toBe(cfg); + // post-dom + CDG identical with and without the pass (the pass did nothing). + expect(computePostDominators(view).ipdom).toEqual(computePostDominators(cfg).ipdom); + expect(cdgSet(view)).toEqual(cdgSet(cfg)); + }); + + it('a for-loop with a body branch is returned UNCHANGED', () => { + // header(1) → body(2) → if(3) → {then(4)|else(5)} → back to header; 1→6 exit + const cfg = mkCfg( + 7, + [ + [0, 1, 'seq'], + [1, 2, 'cond-true'], + [2, 3, 'seq'], + [3, 4, 'cond-true'], + [3, 5, 'cond-false'], + [4, 1, 'loop-back'], + [5, 1, 'loop-back'], + [1, 6, 'cond-false'], + ], + { entry: 0, exit: 6 }, + ); + expect(augmentForPostDom(cfg)).toBe(cfg); + }); +}); + +describe('augmentForPostDom — multi-SCC diverging switch (batch bridge)', () => { + it('bridges BOTH exit-less SCCs in one batch → exit-reachable, CDG emitted', () => { + // dispatch(2) → arm A goto-loop {3,4,5} | arm B goto-loop {6,7,8}; EXIT=1. + // Each arm is a separate exit-less SCC with its own `if` branch (3 and 6). + const cfg = mkCfg( + 9, + [ + [0, 2, 'seq'], + [2, 3, 'switch-case'], + [2, 6, 'switch-case'], + [3, 4, 'cond-true'], + [3, 5, 'seq'], + [4, 5, 'seq'], + [5, 3, 'seq'], + [6, 7, 'cond-true'], + [6, 8, 'seq'], + [7, 8, 'seq'], + [8, 6, 'seq'], + ], + { entry: 0, exit: 1 }, + ); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); + const view = augmentForPostDom(cfg); + // both arm predicates (3 and 6) escape to EXIT — neither arm left trapped. + expect(syntheticEdges(cfg, view).sort()).toEqual(['3->1', '6->1']); + expect(isExitReachableFromAllBlocks(view)).toBe(true); + expect(computeControlDependence(view).edges.length).toBeGreaterThan(0); + }); +}); + +describe('augmentForPostDom — spine/ENTRY in the unreachable closure is not mis-skipped', () => { + it('straight-line statements before the goto label still reach EXIT after the pass', () => { + // ENTRY 0 → stmtA(2) → stmtB(3) → predicate(4) → {work(5)|goto(6)} → back(4) + // The spine {0,2,3} is exit-unreachable too (it feeds the trap), but it must + // NOT be flagged an anomaly — it reaches EXIT for free through the bridge. + const cfg = mkCfg( + 7, + [ + [0, 2, 'seq'], + [2, 3, 'seq'], + [3, 4, 'seq'], + [4, 5, 'cond-true'], + [4, 6, 'seq'], + [5, 6, 'seq'], + [6, 4, 'seq'], + ], + { entry: 0, exit: 1 }, + ); + const view = augmentForPostDom(cfg); + expect(syntheticEdges(cfg, view)).toEqual(['4->1']); // bridge at the predicate + expect(isExitReachableFromAllBlocks(view)).toBe(true); // spine + ENTRY all reach EXIT + }); +}); + +describe('augmentForPostDom — anti-masking (R2): construction anomalies stay skipped', () => { + it('a dangling dead-end block (not in a cycle, no control point) is NOT bridged', () => { + // ENTRY 0 → dead-end(1) with no out-edge; EXIT 2 unreachable from ENTRY. + // No control point in the trapped region → not bridged → still unsound. + const cfg = mkCfg(3, [[0, 1, 'seq']], { entry: 0, exit: 2 }); + const view = augmentForPostDom(cfg); + expect(view).toBe(cfg); // refused to bridge + expect(isExitReachableFromAllBlocks(view)).toBe(false); // skip path stays correct + }); + + it('a branch-less infinite spin (the disconnected-EXIT fixture shape) is NOT bridged', () => { + // ENTRY 0 → 1 ⇄ 2 (branch-less spin); EXIT 3 disconnected. No control point + // anywhere in the trap → refuse to bridge (indistinguishable from a real + // construction error) → the existing soundness gate skips it. + const cfg = mkCfg( + 4, + [ + [0, 1, 'seq'], + [1, 2, 'seq'], + [2, 1, 'seq'], + ], + { entry: 0, exit: 3 }, + ); + const view = augmentForPostDom(cfg); + expect(view).toBe(cfg); + expect(isExitReachableFromAllBlocks(view)).toBe(false); + }); + + it('a MIXED cycle + separate dead-end: cycle bridged, residual dead-end keeps the whole fn skipped', () => { + // ENTRY 0 branches to a recoverable goto-cycle {2,3,4} (branch at 2) AND to a + // separate dead-end block 5. The cycle is bridged, but block 5 stays + // exit-unreachable → the all-or-nothing gate skips the whole function (the + // documented granularity decision: recover the cycle, surface the residual). + const cfg = mkCfg( + 6, + [ + [0, 2, 'cond-true'], + [0, 5, 'cond-false'], + [2, 3, 'cond-true'], + [2, 4, 'seq'], + [3, 4, 'seq'], + [4, 2, 'seq'], + ], + { entry: 0, exit: 1 }, + ); + const view = augmentForPostDom(cfg); + // the cycle WAS bridged (block 2 → EXIT)... + expect(syntheticEdges(cfg, view)).toEqual(['2->1']); + // ...but block 5 is still exit-unreachable → the whole function is skipped. + expect(isExitReachableFromAllBlocks(view)).toBe(false); + }); +}); + +describe('augmentForPostDom — irreducible 2-entry loop (characterization)', () => { + it('bridges the irreducible cycle reachability-sound (control point feeds it from outside)', () => { + // ENTRY 0 → branch(2) → {3 | 4}; 3 ⇄ 4 form an irreducible 2-entry cycle with + // NO internal branch. The control point (block 2) sits OUTSIDE the cycle, so + // the escape attaches to the lowest-index cycle member (block 3). EXIT=1. + const cfg = mkCfg( + 5, + [ + [0, 2, 'seq'], + [2, 3, 'cond-true'], + [2, 4, 'seq'], + [3, 4, 'seq'], + [4, 3, 'seq'], + ], + { entry: 0, exit: 1 }, + ); + expect(isExitReachableFromAllBlocks(cfg)).toBe(false); + const view = augmentForPostDom(cfg); + expect(syntheticEdges(cfg, view)).toEqual(['3->1']); // lowest-index member + expect(isExitReachableFromAllBlocks(view)).toBe(true); + }); +}); + +describe('augmentForPostDom — persistence (analysis-only, never mutates input)', () => { + it('does not mutate the input cfg.edges', () => { + const cfg = reproGotoCycle(); + const before = cfg.edges.map((e) => `${e.from}->${e.to}:${e.kind}`); + const beforeLen = cfg.edges.length; + const view = augmentForPostDom(cfg); + // the view carries the extra edge... + expect(view.edges.length).toBe(beforeLen + 1); + // ...but the ORIGINAL edges array is byte-identical (length + contents). + expect(cfg.edges.length).toBe(beforeLen); + expect(cfg.edges.map((e) => `${e.from}->${e.to}:${e.kind}`)).toEqual(before); + // and the view is a distinct array (not aliasing the input). + expect(view.edges).not.toBe(cfg.edges); + }); + + it('the no-op path returns the SAME object (no clone allocated)', () => { + const cfg = mkCfg(3, [ + [0, 1, 'seq'], + [1, 2, 'seq'], + ]); + expect(augmentForPostDom(cfg)).toBe(cfg); + }); +});