GitNexus/gitnexus/test/unit/cfg/control-dependence.test.ts
Gergo Magyar bbee2da92e 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>
2026-06-14 15:58:50 +00:00

458 lines
18 KiB
TypeScript

import { describe, it, expect } from 'vitest';
import {
computeControlDependence,
type ControlDepEdge,
type CdgLabel,
} 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,
CfgEdgeKind,
FunctionCfg,
} from '../../../src/core/ingestion/cfg/types.js';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
// U3 (#2085 M5) — Ferrante §3.1.1 control dependence over the post-dom tree.
// Hand-built CFG literals plus real-parser regression tests. The labelled
// expected edge sets ARE the spec; the property test (AC2) cross-checks the
// tree-walk against a reference that computes post-dominance INDEPENDENTLY (by
// node-removal reachability, sharing NO code with post-dominators.ts), so a
// post-dominator *direction* bug cannot pass both (#2188 F4).
// ── hand-built CFG helper (edges carry a kind so 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 }));
return {
filePath: 't.ts',
functionStartLine: 1,
functionStartColumn: 0,
entryIndex: entry,
exitIndex: exit,
blocks,
edges: cfgEdges,
};
}
const ser = (e: ControlDepEdge): string => `${e.controllerBlock}->${e.dependentBlock}:${e.label}`;
const serAll = (edges: readonly ControlDepEdge[]): string[] => edges.map(ser);
/**
* Build a successor adjacency list for a CFG (in-range edges only).
*/
function succsOf(cfg: FunctionCfg): number[][] {
const n = cfg.blocks.length;
const succs: number[][] = Array.from({ length: n }, () => []);
for (const e of cfg.edges)
if (e.from >= 0 && e.from < n && e.to >= 0 && e.to < n) succs[e.from].push(e.to);
return succs;
}
/**
* INDEPENDENT post-dominance via node-removal reachability — shares NO code with
* post-dominators.ts (that is the whole point: it must catch a CHK *direction*
* bug that a shared-substrate reference would mirror, #2188 F4). `p`
* 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. 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;
const exit = cfg.exitIndex;
const reach = (avoid: number): boolean => {
if (b === avoid) return false;
const seen = new Set<number>([b]);
const stack = [b];
while (stack.length) {
const x = stack.pop()!;
if (x === exit) return true;
for (const y of succs[x]) {
if (y === avoid || seen.has(y)) continue;
seen.add(y);
stack.push(y);
}
}
return false;
};
// p post-dominates b ⇔ b reaches EXIT, but cannot reach it with p removed.
return reach(-1) && !reach(p);
}
/**
* Reference control-dependence pairs from the Ferrante definition, using the
* INDEPENDENT post-dominance above: N is control-dependent on A iff some CFG
* edge A→B has N post-dominating B while N does NOT strictly post-dominate A.
* Label-agnostic (distinct "A->N" pairs) — the pure definition has no sense.
*/
function referencePairs(cfg: FunctionCfg): Set<string> {
const n = cfg.blocks.length;
const succs = succsOf(cfg);
const pd = (x: number, y: number): boolean => independentPostDom(cfg, succs, x, y);
const pairs = new Set<string>();
for (let a = 0; a < n; a++) {
for (const b of succs[a]) {
if (pd(b, a)) continue; // edge is not a control point
for (let nn = 0; nn < n; nn++) {
const nPostDomB = pd(nn, b);
const nStrictlyPostDomA = nn !== a && pd(nn, a);
if (nPostDomB && !nStrictlyPostDomA) pairs.add(`${a}->${nn}`);
}
}
}
return pairs;
}
describe('computeControlDependence — Ferrante §3.1.1', () => {
it('diamond: each arm is control-dependent on the branch with its own T/F label', () => {
// 0(branch) → 1(then, T), 2(else, F); 1,2 → 3(join) → 4(exit)
const cfg = mkCfg(5, [
[0, 1, 'cond-true'],
[0, 2, 'cond-false'],
[1, 3, 'seq'],
[2, 3, 'seq'],
[3, 4, 'seq'],
]);
const { edges } = computeControlDependence(cfg);
expect(serAll(edges).sort()).toEqual(['0->1:T', '0->2:F']);
// the join (3) post-dominates the branch, so it depends on nothing
expect(edges.some((e) => e.dependentBlock === 3)).toBe(false);
});
it('guard clause: the post-guard body is control-dependent on the guard (the #559/#2086 case)', () => {
// function f(x){ if(!ok(x)) return; use(x); }
// 0(entry) → 1(guard); 1 → 2(return, T) , 1 → 3(use, F); 2,3 → 4(exit)
const cfg = mkCfg(5, [
[0, 1, 'seq'],
[1, 2, 'cond-true'], // !ok(x) → return
[1, 3, 'cond-false'], // else → use(x)
[2, 4, 'return'],
[3, 4, 'seq'],
]);
const { edges } = computeControlDependence(cfg);
// use(x) (block 3) runs only when the guard condition is false → label 'F'
expect(serAll(edges).sort()).toEqual(['1->2:T', '1->3:F']);
});
it('straight-line function (no branches) has no control dependence', () => {
const cfg = mkCfg(3, [
[0, 1, 'seq'],
[1, 2, 'seq'],
]);
expect(computeControlDependence(cfg).edges).toEqual([]);
});
it('while loop: the body depends on the header, and the header is control-dependent on itself', () => {
// 0(entry) → 1(header); 1 → 2(body, T) , 1 → 3(exit, F); 2 → 1 (back-edge)
const cfg = mkCfg(4, [
[0, 1, 'seq'],
[1, 2, 'cond-true'],
[2, 1, 'loop-back'],
[1, 3, 'cond-false'],
]);
const { edges } = computeControlDependence(cfg);
// body(2) control-dep on header(1); header(1) control-dep on itself (the
// loop predicate gates its own re-execution — standard PDG behavior).
expect(serAll(edges).sort()).toEqual(['1->1:T', '1->2:T']);
});
it('switch: every case body is control-dependent on the dispatch (all T in M5)', () => {
// 0(entry) → 1(dispatch); 1 → 2,3,4 (cases); 2,3,4 → 5(exit)
const cfg = mkCfg(6, [
[0, 1, 'seq'],
[1, 2, 'switch-case'],
[1, 3, 'switch-case'],
[1, 4, 'switch-case'],
[2, 5, 'break'],
[3, 5, 'break'],
[4, 5, 'break'],
]);
const { edges } = computeControlDependence(cfg);
expect(serAll(edges).sort()).toEqual(['1->2:T', '1->3:T', '1->4:T']);
});
it('exit-less loop (KTD5): terminates and stays in-range, but the result is KNOWN-UNSOUND (#2188 F2)', () => {
// No block can reach EXIT (block 3), so every ipdom is NO_IPDOM and the
// Ferrante walk degenerates to one edge per control point. The termination /
// in-range invariants MUST hold (the walk hits NO_IPDOM immediately). The
// emitted dependence SET, however, is NOT a sound over-approximation: it both
// drops real dependences and invents spurious ones in exit-unreachable
// regions (#2188 F2). This test pins the degenerate output to document that
// behavior, NOT to bless it; the labels here are likewise indeterminate
// (no controller carries an explicit cond-true/cond-false arm, so the
// fall-through complement resolves to 'F'). The current TS visitor never
// produces such a region (every loop gets a structural header→loopExit edge).
const cfg = mkCfg(4, [
[0, 1, 'seq'],
[1, 2, 'seq'],
[2, 1, 'loop-back'],
]);
const { edges } = computeControlDependence(cfg);
expect(serAll(edges).sort()).toEqual(['0->1:F', '1->2:F', '2->1:F']);
for (const e of edges) {
expect(e.controllerBlock).toBeGreaterThanOrEqual(0);
expect(e.controllerBlock).toBeLessThan(cfg.blocks.length);
expect(e.dependentBlock).toBeGreaterThanOrEqual(0);
expect(e.dependentBlock).toBeLessThan(cfg.blocks.length);
}
});
it('is deterministic (stable sorted order across runs)', () => {
const make = (): FunctionCfg =>
mkCfg(5, [
[0, 1, 'cond-true'],
[0, 2, 'cond-false'],
[1, 3, 'seq'],
[2, 3, 'seq'],
[3, 4, 'seq'],
]);
expect(serAll(computeControlDependence(make()).edges)).toEqual(
serAll(computeControlDependence(make()).edges),
);
});
describe('AC2 — a control dependence exists iff post-dominance fails for the branch', () => {
const fixtures: Record<string, FunctionCfg> = {
diamond: mkCfg(5, [
[0, 1, 'cond-true'],
[0, 2, 'cond-false'],
[1, 3, 'seq'],
[2, 3, 'seq'],
[3, 4, 'seq'],
]),
guard: mkCfg(5, [
[0, 1, 'seq'],
[1, 2, 'cond-true'],
[1, 3, 'cond-false'],
[2, 4, 'return'],
[3, 4, 'seq'],
]),
loop: mkCfg(4, [
[0, 1, 'seq'],
[1, 2, 'cond-true'],
[2, 1, 'loop-back'],
[1, 3, 'cond-false'],
]),
// 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(
7,
[
[0, 1, 'cond-true'],
[0, 5, 'cond-false'],
[1, 2, 'cond-true'],
[1, 3, 'cond-false'],
[2, 4, 'seq'],
[3, 4, 'seq'],
[4, 6, 'seq'],
[5, 6, 'seq'],
],
{ entry: 0, exit: 6 },
),
switchStmt: mkCfg(6, [
[0, 1, 'seq'],
[1, 2, 'switch-case'],
[1, 3, 'switch-case'],
[1, 4, 'switch-case'],
[2, 5, 'break'],
[3, 5, 'break'],
[4, 5, 'break'],
]),
};
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) => {
const cfg = fixtures[name];
const { edges } = computeControlDependence(cfg);
const walkPairs = new Set(edges.map((e) => `${e.controllerBlock}->${e.dependentBlock}`));
expect(walkPairs).toEqual(referencePairs(cfg));
},
);
it.each(Object.keys(fixtures))(
'%s: for every CFG edge, it yields a dependent IFF the target does not post-dominate the source',
(name) => {
const cfg = fixtures[name];
const tree = computePostDominators(cfg);
const { edges } = computeControlDependence(cfg);
for (const e of cfg.edges) {
const failsPostDom = !postDominates(tree, e.to, e.from);
// does THIS edge's source appear as a controller with at least one
// dependent reachable from its target? Equivalent statement of AC2:
// post-dominance failing for (from→to) ⇔ `from` is a control point.
const fromIsControlPoint = edges.some((c) => c.controllerBlock === e.from);
if (failsPostDom) {
expect(
fromIsControlPoint,
`${name}: edge ${e.from}->${e.to} should make ${e.from} a control point`,
).toBe(true);
}
// and a self-post-dominating edge (to post-dominates from) can never
// be the SOLE reason a block is a control point: if from has only
// post-dominating successors it controls nothing.
}
},
);
});
});
describe('computeControlDependence — maxEdges materialization ceiling (#2188)', () => {
// A switch dispatch yields three deduped CDG edges (1->2/3/4, all 'T').
const switchCfg = (): FunctionCfg =>
mkCfg(6, [
[0, 1, 'seq'],
[1, 2, 'switch-case'],
[1, 3, 'switch-case'],
[1, 4, 'switch-case'],
[2, 5, 'break'],
[3, 5, 'break'],
[4, 5, 'break'],
]);
it('stops at the ceiling and reports truncated (deterministic prefix)', () => {
const r = computeControlDependence(switchCfg(), undefined, 2);
expect(r.truncated).toBe(true);
expect(r.edges).toHaveLength(2);
// the prefix is still sorted/deduped, a valid subset of the full result
for (const e of r.edges) expect(['T', 'F']).toContain(e.label);
});
it('maxEdges of 0 means unbounded (full result, not truncated)', () => {
const r = computeControlDependence(switchCfg(), undefined, 0);
expect(r.truncated).toBe(false);
expect(serAll(r.edges).sort()).toEqual(['1->2:T', '1->3:T', '1->4:T']);
});
it('a ceiling at/above the true count is not truncated', () => {
const r = computeControlDependence(switchCfg(), undefined, 3);
expect(r.truncated).toBe(false);
expect(r.edges).toHaveLength(3);
});
});
describe('#2188 F1 — branch-label correctness on the REAL TS visitor (regression)', () => {
// The label is the AC3 "under what condition does X run?" answer. The bug:
// branchSense inferred it from the edge KIND alone, but the M1 visitor wires a
// condition's fall-through FALSE arm as `seq`/`loop-back` (not `cond-false`),
// so guard clauses / loop break got 'T' instead of 'F'. These tests run the
// REAL parser+visitor (the hand-built tests above used a fictional `cond-false`
// edge and could not catch the regression).
const tsVisitor = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
const parser = new Parser();
if (tsVisitor) parser.setLanguage(TypeScript.typescript);
function cdgOf(code: string): { cfg: FunctionCfg; edges: readonly ControlDepEdge[] } {
if (!tsVisitor) throw new Error('no cfgVisitor');
const cfgs = collectFunctionCfgs(parser.parse(code).rootNode, tsVisitor, 't.ts').cfgs;
expect(cfgs.length).toBe(1);
return { cfg: cfgs[0], edges: computeControlDependence(cfgs[0]).edges };
}
const labelOf = (
edges: readonly ControlDepEdge[],
controller: number,
dependent: number,
): CdgLabel | undefined =>
edges.find((e) => e.controllerBlock === controller && e.dependentBlock === dependent)?.label;
it("guard clause: post-guard body runs on the guard's FALSE (seq) arm → 'F'", () => {
const { cfg, edges } = cdgOf(`function f(x){ if (!ok(x)) return; use(x); }`);
const guard = cfg.blocks.find((b) => b.text.includes('ok(x)'))!;
const use = cfg.blocks.find((b) => b.text.includes('use(x)'))!;
expect(labelOf(edges, guard.index, use.index)).toBe('F');
});
it("do/while: body runs on the bottom-test's TRUE (loop-back) arm → 'T'", () => {
const { cfg, edges } = cdgOf(`function f(){ do { body(); } while (c()); }`);
const test = cfg.blocks.find((b) => b.text.includes('c()'))!;
const body = cfg.blocks.find((b) => b.text.includes('body()'))!;
expect(labelOf(edges, test.index, body.index)).toBe('T');
});
it("while+break: post-break tail runs on the if's FALSE (seq) arm → 'F'", () => {
const { cfg, edges } = cdgOf(`function f(o,i){ while (o) { if (i) break; tail(); } }`);
const ifCond = cfg.blocks.find((b) => b.text === '(i)')!;
const tail = cfg.blocks.find((b) => b.text.includes('tail()'))!;
expect(labelOf(edges, ifCond.index, tail.index)).toBe('F');
});
it("if/else still labels both arms correctly (no regression) → then 'T', else 'F'", () => {
const { cfg, edges } = cdgOf(`function f(x){ if (x) { a(); } else { b(); } }`);
const cond = cfg.blocks.find((b) => b.text === '(x)')!;
const thenB = cfg.blocks.find((b) => b.text.includes('a()'))!;
const elseB = cfg.blocks.find((b) => b.text.includes('b()'))!;
expect(labelOf(edges, cond.index, thenB.index)).toBe('T');
expect(labelOf(edges, cond.index, elseB.index)).toBe('F');
});
});