mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
* 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>
655 lines
25 KiB
TypeScript
655 lines
25 KiB
TypeScript
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([]);
|
||
});
|
||
});
|