mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
aa5edf12ab
commit
bbee2da92e
7 changed files with 817 additions and 14 deletions
|
|
@ -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,
|
||||
);
|
||||
|
|
|
|||
335
gitnexus/src/core/ingestion/cfg/synthetic-escape.ts
Normal file
335
gitnexus/src/core/ingestion/cfg/synthetic-escape.ts
Normal 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 };
|
||||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)', () => {
|
||||
|
|
|
|||
357
gitnexus/test/unit/cfg/synthetic-escape.test.ts
Normal file
357
gitnexus/test/unit/cfg/synthetic-escape.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue