GitNexus/gitnexus/test/unit/cfg/reaching-defs.test.ts
Gergő Magyar cdb07289a4
perf(cfg): SSA-sparse reaching-defs to replace the dense-set worklist (#2201) (#2212)
* test(cfg): retain dense reaching-defs as differential oracle + fuzz harness (#2201 U1)

* refactor(cfg): extract shared harvest/adjacency/sweep + swappable in-set computer (#2201 U2)

* perf(cfg): sparse change-driven reaching-defs solver + canonical truncation (#2201 U3,U4)

* perf(cfg): switch production reaching-defs to the sparse solver (#2201 U5)

* perf(cfg): true SSA-sparse reaching-defs solver with auto-dispatch (#2201 U3)

Replace the per-variable worklist (correct but no faster — it still walks
pass-through blocks per binding) with Cytron SSA: CHK dominators + dominance
frontiers + phi-placement + stack renaming over a synthetic entry, answering
block-entry reaching queries by walking the SSA def-use graph (SCC-condensed,
cycle-safe). Pass-through blocks carry the dominating def via the rename stack
and phi-nodes statically capture loop merges, so dense-bindings drops from
O(n^2) to O(n) (5-23x faster, asymptotic) and deep nests are depth-independent.

The sweep now queries a lazy reachingAt accessor with a sparse intra-block
overlay (no full per-block lattice copy). Production auto-dispatches: SSA for
looping functions >=16 blocks (where it pays off, incl. the deep nests the
dense ceiling used to truncate -> ceiling stops firing), dense elsewhere (small
/ loop-free functions, 1.0x — no regression). Throw-edge and unreachable-block
functions fall back to dense (byte-identical). Held byte-identical to the dense
oracle across a 300k-CFG (~1.2M-comparison) differential fuzz.

* test(cfg): R5 contrast — dense ceiling fires, SSA solver converges (#2201 U6)

* bench(cfg): deep-nest scenario + tighten dense-bindings rd budget 10->2 (#2201 U7)

dense-bindings rd_scaling drops 5.2->0.86 (SSA linear); budget tightened to 2.0.
New deep-nest scenario (N nested loops, one carried var) measures rd under the
production blocks×64 ceiling and asserts the SSA solver still COMPUTES full
facts (facts_large_min) where the dense worklist would truncate — the
ceiling-stops-firing acceptance. CFG fingerprints unchanged.

* docs(cfg): document SSA-sparse solver + resolve the WTO no-go note (#2201 U8)

* fix(review): apply autofix feedback (#2201)

- Close the production SSA-dispatcher fuzz-coverage gap: the generator's
  maxBlocks=14 was below SSA_MIN_BLOCKS=16, so the auto-dispatcher's SSA branch
  was never differentially fuzzed. Raise to 36, add a hadLargeLoop coverage
  assertion + a back-edge-into-entry canonical CFG. Validated byte-identical on
  100k random CFGs incl. >=16-block looping shapes via both entry points.
- Correct stale function JSDocs + @internal annotations (dispatch/fallback roles).
- Add an independent rd_all_computed bench gate (catches partial truncation).
- maxBlockVisits comment, SSA_MIN_BLOCKS calibration note, nx->next rename.

* fix(cfg): gate out-of-range binding indices to the dense fallback (#2201 review)

Tri-review (adversarial lane, reproduced) found the SSA path less tolerant than
the dense oracle it replaced: an out-of-range binding index in defs/uses/mayDefs
(a corrupted/stale durable store) crashed the nBindings-sized arrays
(defBlocks[v]/stacks[u]), where dense tolerated it as a Map key. The throw
escaped the unguarded taint/harvest call sites and lost a whole file's taint
layer. Add a malformed-input gate that falls back to the dense solver (which
handles any index), preserving byte-identity AND the graceful per-function
degradation. Add an OOB canonical CFG to the differential fuzz + a production-
entry no-throw unit test (the generator only ever emitted in-range indices, so
this divergent input was structurally invisible).

* perf(cfg): bound the SSA value-graph, fall back to dense when oversized (#2201 review R1)

maxFacts bounds fact materialization in sweepFacts, but nothing bounded the
SSA-sparse solver's φ/value-graph construction. A high-binding-density deep
loop routed to SSA (≥16 blocks + a reachable loop) builds an O(blocks×bindings)
value graph the dense path would have truncated at its maxBlockVisits ceiling
(~1.5 GB measured on a 3000-block × 300-binding function).

Cap the value graph: after φ-placement (where nodeKeys.length == the φ count,
the input-superlinear term) plus a 2×Σgen bound on the renaming nodes, fall
back to computeInSetsDense before paying for renaming + Tarjan SCC. The fallback
is byte-identical (dense is the equivalence oracle) and bounded (dense honors
maxBlockVisits). Mirrors the existing throw/unreachable/OOB-binding gates.

The ceiling is DEFAULT_MAX_SSA_VALUE_GRAPH_NODES (1e6 — far above any real or
benchmarked function; dense-bindings/deep-nest build <1e4), overridable per call
via ReachingDefsLimits.maxSsaValueGraphNodes. The new unit test makes the
otherwise-invisible routing flip observable by pairing the cap with a tight
maxBlockVisits (dense truncates, SSA computes). Equivalence fuzz unchanged
(byte-identical, 20k CFGs green); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(cfg): alias single-source SCC reaching-sets in reachByScc (#2201 review R2)

The SCC-condensation pass built a fresh Set for every SCC and copied each
cross-SCC operand's reaching-set element-by-element — O(defs²) at wide-fan-in φ
merges (a φ over many predecessors, each carrying a large reaching-set).

Add an alias fast path: an SCC with no own leaf keys whose cross-SCC operands
all resolve to ONE source SCC has exactly that source's reaching-set, so share
it by reference instead of copying. This is the common shape (pass-through φ /
single-operand value node). The full union is still built when an SCC has own
keys or genuinely merges ≥2 distinct sources.

Safe to share: reachByScc sets are read-only after construction (operand SCCs
are numbered before s in Tarjan's reverse-topological order and are only
iterated), and contents are identical — set iteration order is irrelevant
because sweepFacts sorts each use's keys before emission (KTD6). Byte-identical
to the dense oracle (30k-CFG fuzz green); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(cfg): fold the SSA reachability gate into the RPO pass (#2201 review R8)

computeInSetsSparse ran a standalone reachability BFS to gate unreachable-block
functions to the dense oracle, then immediately computed a reverse-post-order
over the synthetic-entry graph — two traversals of the same successor structure.

reversePostOrder now returns the reachability bitmap its DFS already builds, and
the sparse path reuses it for the unreachable-block gate (S→entry is S's only
edge, so reachX[b] for b<n is exactly "reachable from entry" — identical to the
removed BFS). One traversal instead of two on every SSA-dispatched function.

The dispatcher's hasReachableLoop pass is left in place: it decides SSA-vs-dense
BEFORE the solver is entered, and computeInSetsSparse must stay self-contained
(the equivalence fuzz drives it directly, bypassing the dispatcher), so the two
cannot share a traversal without coupling the InSetsComputer contract.

Routing and facts unchanged — byte-identical to the dense oracle (30k-CFG fuzz,
including unreachable-block shapes, green); tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(cfg): trim per-statement/per-use/per-block allocations (#2201 review R9)

Three transient allocations in the hot paths, all behavior-preserving:

- sweepFacts: replace the per-statement `new Set([...defs, ...mayDefs])` with a
  direct `includes()` scan over the (1–3 element) def/mayDef arrays, guarded by a
  cheap hasSelfDefs flag that short-circuits pure-use statements.
- sweepFacts: reuse a single scratch array for each use's reaching def-keys
  instead of spreading a fresh array per use. The KTD6 pre-sort still runs in
  place (load-bearing for truncated byte-identity).
- computeInSetsSparse: build dPredsX by skipping consecutive-equal `from` values
  (preds[b] is pre-sorted by buildAdjacency, so duplicates are adjacent) instead
  of a per-block Set + spread + sort; the synthetic entry S = n exceeds every
  block index so it appends in order.

The sweep is shared with the dense oracle, so these stay byte-identical on both
paths — 50k-CFG fuzz (incl. maxFacts truncation, the order-sensitive case)
green; tsc clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs(cfg): correct the sweepFacts truncation byte-identity mechanism (#2201 review R6)

The outer sweepFacts JSDoc attributed a truncated result's cross-solver
byte-identity to the two solvers producing "identical inSets — insertion order
included". That is wrong: the dense (RPO fixpoint) and SSA (renaming/SCC)
solvers deliberately build a loop-carried use's reaching set in DIFFERENT
insertion orders — same set, different order. The actual mechanism is the KTD6
per-use sort that canonicalizes each use's keys by defKey BEFORE the maxFacts
cutoff (already documented correctly on the inner comment). Rewrite the outer
doc to say so. Documentation only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(cfg): extract pure graph sub-stages to reaching-defs-graph.ts (#2201 review R4)

reaching-defs.ts had grown to ~1190 lines with the #2201 SSA rewrite. Move the
self-contained, pure (plain-array) algorithms into a sibling module:

  - reversePostOrder
  - buildDominators (Cooper-Harvey-Kennedy)
  - buildDominanceFrontiers (Cytron)
  - tarjanScc + condenseReachingSets (SCC condensation, alias fast path)
  - hasReachableLoop (dispatcher loop check)
  - unionSets / latticeEquals (def-set / lattice primitives)

The new module has a STRICT one-way dependency (it imports nothing from
reaching-defs.ts — every helper is parameterized over plain arrays/Sets), so
there is no import cycle and each stage is independently testable. reaching-defs.ts
now holds the orchestrator, the two solver bodies, harvest, adjacency, the
statement sweep, and the dispatcher: 1190 → 988 lines.

Pure mechanical extraction — behavior is preserved by the differential
equivalence fuzz (40k CFGs byte-identical) + the reaching-defs unit/snapshot
suites; tsc clean. The helpers are @internal (kept out of the shipped .d.ts by
the stripInternal change).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(pdg): stamp the reaching-defs solver identity for incremental re-analysis (#2201 review R3)

The SSA-sparse rewrite computes full REACHING_DEF facts for deep-loop functions
the old dense worklist truncated to empty at the blocks×64 ceiling. But an
existing `--pdg` index carries those stale-truncated rows, and nothing forced a
re-analysis: RepoMeta.pdg had no solver-identity key, so an upgraded run over an
unchanged file kept the incremental fast path and never recomputed.

Add a constant `reachingDefSolver: 'ssa-sparse-v1'` to the resolved pdg stamp
(and to the RepoMeta['pdg'] type). It rides the existing key-union
pdgModeMismatch comparator: a pre-#2201 stamp lacks the key, so
'ssa-sparse-v1' !== undefined trips one full writeback that recomputes the
fuller coverage — no `--force` needed — exactly like the M2 REACHING_DEF cap and
M5 CDG cap upgrade paths. A matching post-#2201 stamp compares equal, so there
is no spurious re-analysis churn on steady-state re-runs.

Tests: new pre-#2201→SSA upgrade block in pdg-mode-flip.test.ts (stamp present,
absent-key mismatch, identical-stamp no-churn) + the persisted-stamp shape
assertions and resolvePdgConfig DEFAULTS updated for the new key. tsc clean;
pdg-mode-flip + run-analyze suites green (55/55).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* build(ts): stripInternal so @internal test-only exports stay out of the shipped .d.ts (#2201 review R5)

computeReachingDefsDense/computeReachingDefsSparse are exported only for the
equivalence fuzz and tagged @internal, but `declaration: true` emitted them into
the public dist/**/*.d.ts. stripInternal removes any @internal-tagged export from
the declaration output.

This is repo-wide, which is the intended behavior: the same applies to every
other test-only @internal export (hf-env's withDownloadTimeout etc., worker-pool's
buildDispatchMessage/crashSignature, parse-impl's handleWorkerStartupFailure, the
logger/safe-parse test resets, and the new reaching-defs-graph SSA helpers) — all
of which are documented as not-public.

Verified:
- declaration emit succeeds with no TS4094/TS9006 ("cannot be named") errors;
- the @internal functions are gone from the emitted .d.ts (reaching-defs-graph.d.ts
  is now `export {};`), while public symbols (computeReachingDefs) remain;
- gitnexus-web — the only cross-package consumer — typechecks clean and imports
  only from gitnexus-shared, never from gitnexus internals;
- runtime .js and the vitest/tsx tests are source-based, so unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* test(bench): add wide-merge scenario + tighten deep-nest facts floor (#2201 review R7)

wide-merge: N bindings, each assigned in a 3-way branch (a wide multi-operand φ
per binding) inside a loop, then all used after the merge. Unlike dense-bindings
(one chained redef per `if`), every binding fans into its own wide φ, so the
scenario exercises φ-placement + renaming + the reachByScc condensation across
many independent wide merges. N bindings × constant arms ⇒ O(N) facts, so the
gate is rd_scaling LINEARITY (measured ~1.07; budget 2.0 catches a regression to
the per-binding-rescan O(N²) class the reachByScc alias path guards against). It
runs the production SSA path (10007 blocks + a loop) and computes all facts under
the blocks×64 budget (facts_large_min 24000 of a measured 26008 + the
rd_all_computed gate).

deep-nest: tighten facts_large_min 100 → 150 (measured 164) so a partial-
truncation regression that still cleared 100 — but lost facts — now fails, with
~9% headroom for noise.

bench --check PASS (9 scenarios) under --expose-gc; all existing CFG fingerprints
unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* style(cfg): drop trailing blank line in reaching-defs.ts (prettier)

Whitespace-only — a stray trailing newline left by the U4 extraction. `prettier
--check` (the root format CI gate) now passes on every changed file. No behavior
change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 19:16:53 +01:00

655 lines
25 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 Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import {
computeReachingDefs,
computeReachingDefsDense,
computeReachingDefsSparse,
type DefUseFact,
} from '../../../src/core/ingestion/cfg/reaching-defs.js';
import type {
BasicBlockData,
BindingEntry,
CfgEdgeData,
FunctionCfg,
StatementFacts,
} from '../../../src/core/ingestion/cfg/types.js';
// U3 (#2082 M2) — the GEN/KILL fixpoint + intra-block statement sweep. The
// classic lattice hazards (kill ordering, branch-merge union, loop-carried
// defs, self-loops, unreachable blocks) are pinned on hand-built FunctionCfg
// literals with zero tree-sitter dependency, mirroring cfg-builder.test.ts;
// shadowing/try-finally acceptance runs parser-direct through the U1 harvest.
// ── hand-built CFG helpers ──────────────────────────────────────────────────
interface BlockSpec {
readonly kind?: BasicBlockData['kind'];
readonly stmts?: StatementFacts[];
}
function mkCfg(blocks: BlockSpec[], edges: [number, number][], bindings: string[]): FunctionCfg {
const bindingTable: BindingEntry[] = bindings.map((name, i) => ({
name,
declLine: i + 1,
declColumn: 0,
kind: 'let',
}));
return {
filePath: 'hand.ts',
functionStartLine: 1,
functionEndLine: 99,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: 1,
blocks: blocks.map((b, index) => ({
index,
startLine: index + 1,
endLine: index + 1,
text: '',
kind: b.kind ?? (index === 0 ? 'entry' : index === 1 ? 'exit' : 'normal'),
statements: b.stmts ?? [],
})),
edges: edges.map(([from, to]) => ({ from, to, kind: 'seq' }) as CfgEdgeData),
bindings: bindingTable,
};
}
const stmt = (line: number, defs: number[] = [], uses: number[] = []): StatementFacts => ({
line,
defs,
uses,
});
/** Compact "defBlock:defStmt->useBlock:useStmt:binding" rendering for asserts. */
const render = (facts: readonly DefUseFact[]): string[] =>
facts.map(
(f) =>
`${f.def.blockIndex}:${f.def.stmtIndex}->${f.use.blockIndex}:${f.use.stmtIndex}:${f.bindingIdx}`,
);
// ── parser-direct helpers (shadowing / finally acceptance) ──────────────────
const visitor = createTypeScriptCfgVisitor();
function cfgOf(code: string, index = 0): FunctionCfg {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
const root = parser.parse(code).rootNode as SyntaxNode;
const fns: SyntaxNode[] = [];
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) fns.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
const cfg = visitor.buildFunctionCfg(fns[index], 'fixture.ts');
if (!cfg) throw new Error('no cfg');
return cfg;
}
const nameIdx = (cfg: FunctionCfg, name: string): number[] =>
(cfg.bindings ?? []).map((b, i) => (b.name === name ? i : -1)).filter((i) => i >= 0);
// ── tests ───────────────────────────────────────────────────────────────────
describe('computeReachingDefs — kill/gen fundamentals (hand-built)', () => {
it('straight line: reassignment kills the prior def (R6)', () => {
// block 2: x=1; x=2; y=x
const cfg = mkCfg(
[{}, {}, { stmts: [stmt(10, [0]), stmt(11, [0]), stmt(12, [1], [0])] }],
[
[0, 2],
[2, 1],
],
['x', 'y'],
);
const r = computeReachingDefs(cfg);
expect(r.status).toBe('computed');
expect(render(r.facts)).toEqual(['2:1->2:2:0']); // ONLY the second def reaches
expect(r.defCount).toBe(3);
expect(r.useCount).toBe(1);
});
it('branch merge (diamond): defs from BOTH arms reach the join use', () => {
// 0→2(def x)→{3,4 both def x}→5(use x)→1
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(10, [0])] },
{ stmts: [stmt(20, [0])] },
{ stmts: [stmt(30, [0])] },
{ stmts: [stmt(40, [], [0])] },
],
[
[0, 2],
[2, 3],
[2, 4],
[3, 5],
[4, 5],
[5, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(render(r.facts).sort()).toEqual(['3:0->5:0:0', '4:0->5:0:0']);
});
it('loop back-edge: pre-loop def AND loop-carried redef both reach the header use', () => {
// 0→2(def x)→3(use x = header)→4(def x, body)→3(back); 3→1(exit)
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(10, [0])] },
{ stmts: [stmt(20, [], [0])] },
{ stmts: [stmt(30, [0])] },
],
[
[0, 2],
[2, 3],
[3, 4],
[4, 3],
[3, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(render(r.facts).sort()).toEqual(['2:0->3:0:0', '4:0->3:0:0']);
});
it('self-loop block converges with the loop-carried def visible to its own use', () => {
// block 2 loops to itself: use x; def x
const cfg = mkCfg(
[{}, {}, { stmts: [stmt(10, [], [0]), stmt(11, [0])] }],
[
[0, 2],
[2, 2],
[2, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
// the block's own def flows around the self-loop into its use
expect(render(r.facts)).toEqual(['2:1->2:0:0']);
});
it('unreachable block: its defs reach nothing; reachable uses see only reachable defs', () => {
// 2(def x)→3(use x); 4 is DISCONNECTED and also defs x
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(10, [0])] },
{ stmts: [stmt(20, [], [0])] },
{ stmts: [stmt(30, [0])] },
],
[
[0, 2],
[2, 3],
[3, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(render(r.facts)).toEqual(['2:0->3:0:0']);
});
it('intra-block sweep: a use BEFORE the same-block def sees the incoming def', () => {
// 2: def x. 3: use x (stmt0); def x (stmt1); use x (stmt2)
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(10, [0])] },
{ stmts: [stmt(20, [], [0]), stmt(21, [0]), stmt(22, [], [0])] },
],
[
[0, 2],
[2, 3],
[3, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(render(r.facts).sort()).toEqual(['2:0->3:0:0', '3:1->3:2:0']);
});
it('def+use in one statement: the use sees prior defs AND the same-statement def', () => {
// StatementFacts carries no intra-statement order, so `x += 1`
// (read-then-write) and `if ((m = f()) && m.p)` (write-then-read) are
// indistinguishable — the sweep emits BOTH the prior def and the
// same-statement self-def (sound over-approximation; missing the
// assign-and-test idiom's def→use would be a taint false negative).
const cfg = mkCfg(
[{}, {}, { stmts: [stmt(10, [0]), stmt(11, [0], [0])] }],
[
[0, 2],
[2, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(render(r.facts).sort()).toEqual(['2:0->2:1:0', '2:1->2:1:0']);
});
});
describe('computeReachingDefs — determinism and convergence', () => {
it('permuted edge order produces byte-identical sorted facts', () => {
const blocks: BlockSpec[] = [
{},
{},
{ stmts: [stmt(1, [0]), stmt(2, [1])] },
{ stmts: [stmt(3, [0], [1])] },
{ stmts: [stmt(4, [1], [0])] },
{ stmts: [stmt(5, [], [0, 1])] },
];
const edges: [number, number][] = [
[0, 2],
[2, 3],
[2, 4],
[3, 5],
[4, 5],
[5, 3],
[5, 1],
];
const base = computeReachingDefs(mkCfg(blocks, edges, ['x', 'y']));
for (let i = 0; i < 5; i++) {
const shuffled = [...edges].reverse();
shuffled.push(shuffled.shift() as [number, number]);
const r = computeReachingDefs(mkCfg(blocks, shuffled, ['x', 'y']));
expect(render(r.facts)).toEqual(render(base.facts));
}
});
it('nested loops (depth 3) converge with loop-carried defs intact', () => {
// 2 chains into three nested loop headers 3,4,5; innermost body 6 defs x.
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(1, [0])] },
{ stmts: [stmt(2, [], [0])] },
{ stmts: [stmt(3, [], [0])] },
{ stmts: [stmt(4, [], [0])] },
{ stmts: [stmt(5, [0])] },
],
[
[0, 2],
[2, 3],
[3, 4],
[4, 5],
[5, 6],
[6, 5],
[5, 4],
[4, 3],
[3, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
// every header use sees both the init def and the innermost redef
for (const useBlock of [3, 4, 5]) {
const defs = r.facts
.filter((f) => f.use.blockIndex === useBlock)
.map((f) => f.def.blockIndex);
expect(new Set(defs)).toEqual(new Set([2, 6]));
}
});
it('no-facts fallback: a CFG without statement facts reports no-facts, no throw', () => {
const bare: FunctionCfg = {
filePath: 'hand.ts',
functionStartLine: 1,
functionEndLine: 2,
functionStartColumn: 0,
entryIndex: 0,
exitIndex: 1,
blocks: [
{ index: 0, startLine: 1, endLine: 1, text: '', kind: 'entry' },
{ index: 1, startLine: 2, endLine: 2, text: '', kind: 'exit' },
],
edges: [{ from: 0, to: 1, kind: 'seq' }],
};
const r = computeReachingDefs(bare);
expect(r.status).toBe('no-facts');
expect(r.facts).toEqual([]);
});
it('maxFacts truncation: deterministic prefix + truncated status', () => {
// fan-out: 4 defs of x in parallel arms, then 4 uses → 16 facts
const arms = [2, 3, 4, 5];
const uses = [6, 7, 8, 9];
const blocks: BlockSpec[] = [{}, {}];
for (const a of arms) blocks[a] = { stmts: [stmt(a, [0])] };
for (const u of uses) blocks[u] = { stmts: [stmt(u, [], [0])] };
const edges: [number, number][] = [];
for (const a of arms) edges.push([0, a], [a, 6]);
edges.push([6, 7], [7, 8], [8, 9], [9, 1]);
const full = computeReachingDefs(mkCfg(blocks, edges, ['x']));
expect(full.status).toBe('computed');
expect(full.facts).toHaveLength(16);
const capped = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxFacts: 5 });
expect(capped.status).toBe('truncated');
expect(capped.facts).toHaveLength(5);
// deterministic prefix: re-running yields the same truncated set
const again = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxFacts: 5 });
expect(render(again.facts)).toEqual(render(capped.facts));
// telemetry counts are truncation-independent
expect(capped.defCount).toBe(full.defCount);
expect(capped.useCount).toBe(full.useCount);
});
it('maxBlockVisits ceiling: a budget below convergence bails to a sound empty truncated', () => {
// entry → body (self-loop, forces re-processing) → exit; body defs+uses x.
const blocks: BlockSpec[] = [{}, {}, { stmts: [stmt(3, [0], [0])] }];
const edges: [number, number][] = [
[0, 2],
[2, 2], // self-loop → the fixpoint re-visits block 2
[2, 1],
];
// Unbounded (and a generous budget) converge with the loop-carried fact.
const full = computeReachingDefs(mkCfg(blocks, edges, ['x']));
expect(full.status).toBe('computed');
expect(full.facts.length).toBeGreaterThan(0);
const budgeted = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxBlockVisits: 1000 });
expect(budgeted.status).toBe('computed');
expect(render(budgeted.facts)).toEqual(render(full.facts)); // byte-identical for normal code
// A budget below convergence cannot reach the fixpoint, so facts would be
// unsound → return NONE (sound), status 'truncated', telemetry preserved.
const capped = computeReachingDefs(mkCfg(blocks, edges, ['x']), { maxBlockVisits: 1 });
expect(capped.status).toBe('truncated');
expect(capped.facts).toEqual([]);
expect(capped.defCount).toBe(full.defCount);
});
it('#2201 R5: the ceiling fires on the dense oracle but not on the SSA solver', () => {
// Contrast the two solvers on a looping CFG under a budget below the dense
// worklist's convergence: the dense oracle truncates to a sound-empty result
// (the ceiling fires), while the SSA solver — which has no fixpoint
// iteration — always converges (the ceiling that fired on the dense worklist
// effectively never fires). The facts the SSA solver computes are identical
// to the dense oracle's unbounded result. This is the #2201 acceptance: the
// blocks×64 ceiling stops firing on deep loops.
const blocks: BlockSpec[] = [{}, {}, { stmts: [stmt(3, [0], [0])] }];
const edges: [number, number][] = [
[0, 2],
[2, 2], // self-loop → the dense fixpoint must re-visit block 2
[2, 1],
];
const denseFull = computeReachingDefsDense(mkCfg(blocks, edges, ['x']));
const denseCeiling = computeReachingDefsDense(mkCfg(blocks, edges, ['x']), {
maxBlockVisits: 1,
});
const sparse = computeReachingDefsSparse(mkCfg(blocks, edges, ['x']), { maxBlockVisits: 1 });
expect(denseFull.status).toBe('computed');
expect(denseFull.facts.length).toBeGreaterThan(0);
expect(denseCeiling.status).toBe('truncated'); // ceiling fires on the dense worklist
expect(sparse.status).toBe('computed'); // SSA ignores the ceiling — it never fires
expect(render(sparse.facts)).toEqual(render(denseFull.facts)); // and the facts match
});
it('#2201: an out-of-range binding index in a ≥16-block loop does NOT crash the SSA path', () => {
// A corrupted/stale store can carry a binding index ≥ nBindings. The dense
// solver tolerates it (Map-keyed lattice); the SSA path's nBindings-sized
// arrays would throw. The production dispatcher routes ≥16-block looping
// functions to SSA, so without the malformed-input gate the throw would
// escape the (unguarded) taint/harvest callers and lose a whole file's taint
// layer. The gate falls back to dense — no throw, byte-identical to dense.
const blocks: BlockSpec[] = [{ stmts: [stmt(1, [0], [])] }];
const edges: [number, number][] = [];
for (let i = 1; i <= 18; i++) {
blocks.push({ stmts: [stmt(i + 1, i === 1 ? [5] : [0], [i === 1 ? 5 : 0])] }); // block 1 uses/defs OOB index 5
edges.push([i - 1, i]);
}
edges.push([18, 1]); // back-edge → loop; 19 blocks total, ≥16 → SSA dispatch
const cfg = mkCfg(blocks, edges, ['x']); // nBindings = 1; index 5 is out of range
expect(cfg.blocks.length).toBeGreaterThanOrEqual(16);
let prod: ReturnType<typeof computeReachingDefs> | undefined;
expect(() => {
prod = computeReachingDefs(cfg); // must NOT throw (gate → dense fallback)
}).not.toThrow();
const dense = computeReachingDefsDense(cfg);
expect(prod!.status).toBe(dense.status);
expect(render(prod!.facts)).toEqual(render(dense.facts)); // byte-identical to the tolerant dense path
});
it('#2201 R1: an oversized SSA value graph falls back to the dense oracle (byte-identical)', () => {
// A ≥16-block looping multi-binding CFG → the production dispatcher routes it
// to the SSA-sparse path. `maxFacts` bounds only fact materialization, not the
// φ/value-graph the sparse path builds first; `maxSsaValueGraphNodes` caps that
// graph and falls back to the dense oracle when it would be too large. Because
// the fallback is byte-identical to dense, the routing flip is made OBSERVABLE
// via a tight `maxBlockVisits`: dense honors the ceiling (truncates), the SSA
// path ignores it (computes) — so the same budget yields different statuses
// depending on which solver ran.
const K = 4; // bindings
const blocks: BlockSpec[] = [{}, {}]; // 0 entry, 1 exit
const edges: [number, number][] = [[0, 2]];
const BODY = 18; // body blocks 2..19 → 20 blocks total (≥ SSA_MIN_BLOCKS)
for (let i = 0; i < BODY; i++) {
const b = 2 + i;
blocks[b] = { stmts: [stmt(b * 10, [i % K], [(i + 1) % K])] };
if (i < BODY - 1) edges.push([b, b + 1]);
}
edges.push([2 + BODY - 1, 2]); // back-edge → reachable loop (forces SSA dispatch)
edges.push([2, 1]); // exit
const bindings = Array.from({ length: K }, (_, i) => `v${i}`);
const mk = () => mkCfg(blocks, edges, bindings);
expect(mk().blocks.length).toBeGreaterThanOrEqual(16);
const denseFull = computeReachingDefsDense(mk());
expect(denseFull.status).toBe('computed');
expect(denseFull.facts.length).toBeGreaterThan(0);
// Tiny node cap, unbounded visits → falls back to dense → byte-identical.
const cappedUnbounded = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 1 });
expect(cappedUnbounded.status).toBe(denseFull.status);
expect(render(cappedUnbounded.facts)).toEqual(render(denseFull.facts));
// Tiny node cap + tight block-visit budget → fallback to dense, whose ceiling
// then fires (truncated, empty). This is the observable proof the cap diverted
// the solve to the dense path.
const cappedBudgeted = computeReachingDefs(mk(), {
maxSsaValueGraphNodes: 1,
maxBlockVisits: 1,
});
expect(cappedBudgeted.status).toBe('truncated');
expect(cappedBudgeted.facts).toEqual([]);
// Default (huge) cap + the SAME tight budget → SSA path runs (no fixpoint
// iteration → ceiling never fires) and computes the full facts.
const uncapped = computeReachingDefs(mk(), { maxBlockVisits: 1 });
expect(uncapped.status).toBe('computed');
expect(render(uncapped.facts)).toEqual(render(denseFull.facts));
// Boundary monotonicity: a cap well above the graph stays on SSA (computes
// under the tight budget), a cap well below falls back (truncates).
const above = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 100_000, maxBlockVisits: 1 });
expect(above.status).toBe('computed');
const below = computeReachingDefs(mk(), { maxSsaValueGraphNodes: 5, maxBlockVisits: 1 });
expect(below.status).toBe('truncated');
});
});
describe('computeReachingDefs — parser-direct acceptance (with U1/U2)', () => {
it('shadowing: inner let does NOT kill the outer binding across the block (R4)', () => {
const cfg = cfgOf(`function f() {
let x = 1;
{ let x = 2; sink(x); }
sink(x);
}`);
const [outer, inner] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const outerUse = r.facts.filter((f) => f.bindingIdx === outer);
const innerUse = r.facts.filter((f) => f.bindingIdx === inner);
expect(innerUse).toHaveLength(1);
expect(outerUse).toHaveLength(1);
// the trailing sink(x) sees the OUTER def — the inner block didn't kill it
expect(outerUse[0].def.line).toBe(2);
expect(outerUse[0].use.line).toBe(4);
});
it('try/catch over-approximation: a try-body def reaches a catch-body use (R10)', () => {
const cfg = cfgOf(`function f() {
let x = seed();
try { x = risky(); } catch (e) { sink(x); }
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const catchUses = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 3);
// BOTH the seed def and the try-body redef may reach the catch use
expect(new Set(catchUses.map((f) => f.def.line))).toEqual(new Set([2, 3]));
});
it('finally redefinition on the early-exit/normal paths kills the original (R9 + U2)', () => {
const cfg = cfgOf(`function f(c) {
let x = 1;
try {
if (c) { return probe(x); }
} finally {
x = 2;
}
return sink(x);
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
// the early return's use happens BEFORE finally runs → sees x = 1 (line 2)
const probeUse = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 4);
expect(probeUse.map((f) => f.def.line)).toEqual([2]);
// the post-try use sits behind the finally on EVERY path → sees ONLY x = 2
const sinkUse = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 8);
expect(sinkUse.map((f) => f.def.line)).toEqual([6]);
});
it('params reach their uses from the ENTRY record', () => {
const cfg = cfgOf(`function f(a) { return a + 1; }`);
const [a] = nameIdx(cfg, 'a');
const r = computeReachingDefs(cfg);
const fact = r.facts.find((f) => f.bindingIdx === a);
expect(fact).toBeDefined();
expect(fact!.def.blockIndex).toBe(cfg.entryIndex);
});
it('loop-carried accumulator: both the init and in-loop defs reach the post-loop use', () => {
const cfg = cfgOf(`function f(xs) {
let sum = 0;
for (const x of xs) { sum += x; }
return sum;
}`);
const [sum] = nameIdx(cfg, 'sum');
const r = computeReachingDefs(cfg);
const retUse = r.facts.filter((f) => f.bindingIdx === sum && f.use.line === 4);
expect(new Set(retUse.map((f) => f.def.line))).toEqual(new Set([2, 3]));
});
});
describe('computeReachingDefs — tri-review soundness fixes (#2160 review)', () => {
it('may-def gen does NOT kill: prior def survives a conditional assignment (hand-built)', () => {
// block 2: def x. block 3: stmt with MAY-def of x. block 4: use x.
const cfg = mkCfg(
[
{},
{},
{ stmts: [stmt(10, [0])] },
{ stmts: [{ line: 20, defs: [], uses: [], mayDefs: [0] }] },
{ stmts: [stmt(30, [], [0])] },
],
[
[0, 2],
[2, 3],
[3, 4],
[4, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
// BOTH the unconditional def and the conditional one reach the use
expect(render(r.facts).sort()).toEqual(['2:0->4:0:0', '3:0->4:0:0']);
});
it('short-circuit conditional def: the not-taken path keeps the prior def (parser-direct, P1)', () => {
const cfg = cfgOf(`function f(a) {
let x = source();
if (a && (x = clean())) {}
sink(x);
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const sinkUses = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 4);
// BOTH source (line 2) and clean (line 3) reach sink — pre-fix, source was
// falsely killed (taint false negative on the lazy-init idiom)
expect(new Set(sinkUses.map((f) => f.def.line))).toEqual(new Set([2, 3]));
});
it('labeled non-loop block: break keeps the real continuation (parser-direct, P1)', () => {
const cfg = cfgOf(`function f(c) {
let x = 1;
blk: { if (c) break blk; x = 2; }
sink(x);
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const sinkUses = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 4);
// the break path preserves x=1; the fall-through path redefines to x=2
expect(new Set(sinkUses.map((f) => f.def.line))).toEqual(new Set([2, 3]));
});
it('doubly-labeled loop: `break outer` resolves to the loop exit, keeping post-loop facts (P1)', () => {
const cfg = cfgOf(`function f(c) {
let x = 1;
outer: inner: do { if (c) break outer; x = 2; } while (g());
sink(x);
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const sinkUses = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 4);
expect(new Set(sinkUses.map((f) => f.def.line))).toEqual(new Set([2, 3]));
});
it('throw edges deliver INTERMEDIATE defs of a coalesced block to the handler (parser-direct, P1)', () => {
const cfg = cfgOf(`function f(a) {
let x = seed(a);
try {
x = parse(a);
x = normalize(x);
} catch (e) {
sink(x);
}
}`);
const [x] = nameIdx(cfg, 'x');
const r = computeReachingDefs(cfg);
const sinkUses = r.facts.filter((f) => f.bindingIdx === x && f.use.line === 7);
// seed (pre-try), parse (intermediate — normalize may throw with parse's
// value live), and normalize (its own RHS use may throw) all reach sink
expect(new Set(sinkUses.map((f) => f.def.line))).toEqual(new Set([2, 4, 5]));
});
it('a block with ≥ STMT_STRIDE statements reports overflow with zero facts (no aliasing)', () => {
const shared = { line: 1, defs: [], uses: [] };
const huge = new Array(1 << 21).fill(shared);
const cfg = mkCfg(
[{}, {}, { stmts: huge as StatementFacts[] }],
[
[0, 2],
[2, 1],
],
['x'],
);
const r = computeReachingDefs(cfg);
expect(r.status).toBe('overflow');
expect(r.facts).toEqual([]);
});
});