GitNexus/gitnexus/test/unit/cfg/control-dependence.test.ts

537 lines
22 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
functionEndLine: blockCount,
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('computeControlDependence — reverse-DF formulation regressions (#2195)', () => {
// The Ferrante up-walk was replaced by the reverse-CFG post-dominance frontier
// (Cytron/CFRWZ 1991; LLVM ReverseIDFCalculator / Joern CdgPass). These pin the
// three invariants the rewrite must preserve: multi-label-per-pair, the
// self-edge / NO_IPDOM seed guard, and linear (not quadratic) scaling.
it('keeps BOTH senses when one controller reaches a dependent via opposite arms', () => {
// Minimised goto-cycle shape: A (block 1) reaches X (block 2) by BOTH its true
// arm and its false arm, with a third escape (1→3) so X does NOT post-dominate
// A (else it would be a plain diamond join controlling nothing). The PDF must
// union the label SET — the old (a, cur, label) dedup kept both rows — not
// collapse the pair to a single sense.
const cfg = mkCfg(
4,
[
[0, 1, 'seq'],
[1, 2, 'cond-true'],
[1, 2, 'cond-false'],
[1, 3, 'seq'], // escape so X(2) is not a post-dominator of A(1)
[2, 3, 'seq'],
],
{ entry: 0, exit: 3 },
);
const { edges } = computeControlDependence(cfg);
expect(serAll(edges).sort()).toEqual(['1->2:F', '1->2:T']);
});
it('a literal self-edge never invents a self control-dependence (PDF_local a!==x guard)', () => {
// Standard while loop (header 1, body 2) PLUS a spurious LITERAL self-edge
// 1→1. The header legitimately depends on itself via the body back-edge
// (1->1:T is inherited through PDF_up), but the literal self in-edge must
// contribute NOTHING: without the `a !== x` PDF_local guard it would seed a
// bogus 1->1:F (the loop-back complement of the header's true arm).
const cfg = mkCfg(4, [
[0, 1, 'seq'],
[1, 2, 'cond-true'],
[2, 1, 'loop-back'],
[1, 1, 'loop-back'], // spurious literal self-edge — must be ignored
[1, 3, 'cond-false'],
]);
const { edges } = computeControlDependence(cfg);
expect(serAll(edges).sort()).toEqual(['1->1:T', '1->2:T']);
expect(edges.some((e) => ser(e) === '1->1:F')).toBe(false);
});
it('scales linearly on a fan-into-chain (perf tripwire: was a Θ(N²) up-walk)', () => {
// fanChain(N): block 0 fans one edge to every node of a length-(N-2) spine
// whose ipdom chain tops out at EXIT. The old Ferrante up-walk re-climbed the
// shared spine once per fan edge → Θ(N²) (~7.1s at N=16k); the reverse-DF form
// is O(N+E+output) (~13ms). Coarse wall-clock tripwire, not a microbenchmark:
// a revert to quadratic blows past the ceiling by >5×. post-dom is built
// outside the timed region so this isolates the rewritten function.
const N = 16000;
const M = N - 2; // chain blocks 1..M; block 0 = fan source; block N-1 = EXIT
const exit = N - 1;
const edges: [number, number, CfgEdgeKind][] = [];
for (let i = 1; i <= M; i++) {
edges.push([0, i, 'switch-case']); // fan: 0 → every chain node
edges.push([i, i === M ? exit : i + 1, 'seq']); // spine: i → i+1 (→ EXIT)
}
const cfg = mkCfg(N, edges, { entry: 0, exit });
const postDom = computePostDominators(cfg); // built OUTSIDE the timed region
const t0 = performance.now();
const { edges: cdg } = computeControlDependence(cfg, postDom);
const ms = performance.now() - t0;
// Block 0 controls every chain node EXCEPT the last (M): the sole edge into
// EXIT is M→exit, so M post-dominates the whole fan (ipdom[0]===M) and is
// controlled by nothing. The other M-1 spine nodes are each 0-controlled.
// Assert IDENTITY too (not just count) so a fast-but-wrong reimplementation
// emitting M-1 mis-attributed edges can't pass on length alone.
expect(cdg).toHaveLength(M - 1);
expect(cdg.every((e) => e.controllerBlock === 0)).toBe(true);
expect(cdg.every((e) => e.dependentBlock >= 1 && e.dependentBlock < M)).toBe(true);
expect(new Set(cdg.map((e) => e.dependentBlock)).size).toBe(M - 1); // all distinct
expect(ms).toBeLessThan(1000);
});
});
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');
});
});