fix(cfg): synthetic-escape pass restores CDG for exit-unreachable cycles (#2195 U1)

Unconditional goto-cycles (C/C++/C#/Go) wire a backward seq edge with no
structural exit-escape edge, so EXIT becomes non-reverse-reachable and
emitFileCdg silently skipped ALL control-dependence for the function.

New cfg/synthetic-escape.ts: a pure deterministic SCC routine (iterative
Tarjan, sorted adjacency) + augmentForPostDom(cfg). No-op when EXIT is
already reverse-reachable (terminating fns + visitor-escaped loops are
byte-identical — returns the same object). Otherwise it batch-bridges
every exit-less SCC by adding an ANALYSIS-ONLY escape edge from the SCC's
controlling block (highest out-degree branch; lowest-index tie-break) to
EXIT, on a shallow-cloned FunctionCfg — never mutating persisted
cfg.edges. emitFileCdg threads that augmented view through BOTH
isExitReachableFromAllBlocks AND computeControlDependence (the Ferrante
walk re-reads cfg.edges, so a tree-only augmentation would be wrong).

Precision (anti-masking): only a trapped region containing a control
point (>=2-successor block) is bridged — a branch-less trapped region
carries no recoverable control-dependence and is indistinguishable from a
genuine construction anomaly, so it stays on the skip path (the existing
disconnected-block skip test still skips, skippedUnsoundFunctions===1). A
residual non-cycle dangling block is never bridged.

repro `void handler(int a){ start: if(a>0){work();} goto start; }`:
before exitReachable=false/CDG=0 → after one synthetic 2->1 edge,
exitReachable=true, exact CDG = {2->2:T,2->2:F,2->3:T,2->4:T,2->4:F}
(pinned exactly, not CDG>0 — catches a wrong representative). AC2 property
test extended to the augmented graph; per-language goto-cycle regressions
(C/C++/C#/Go). 199 cfg tests green; bench --check fingerprints unchanged
(analysis-only, zero persisted drift).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 15:58:50 +00:00
parent aa5edf12ab
commit bbee2da92e
7 changed files with 817 additions and 14 deletions

View file

@ -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,
);

View file

@ -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<number>(n).fill(-1);
const members: number[][] = [];
const index = new Array<number>(n).fill(-1);
const lowlink = new Array<number>(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 };

View file

@ -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', () => {

View file

@ -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) => {

View file

@ -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', () => {

View file

@ -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)', () => {

View file

@ -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);
});
});