GitNexus/gitnexus/bench/cfg/measure.mjs
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

739 lines
34 KiB
JavaScript
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.

/**
* Build-free CFG-construction measurement harness (#2081 M1).
*
* Times `collectFunctionCfgs` (the per-function CFG builder the parse worker
* runs on a `--pdg` run) on synthetic TS sources at two sizes, in three
* scenarios that each stress a distinct cost dimension:
* - `straight-line`: ONE function with N coalescing statements — stresses the
* basic-block text accumulation (the `extendBlock` path);
* - `many-functions`: N small branchy functions — stresses the collect walk +
* per-function build + the tree-sitter `namedChildren` accesses;
* - `branchy`: ONE function with N sequential `if`s — stresses block/edge
* growth within a single CFG.
*
* For each scenario it reports three scaling ratios at small→large
* (`(metric_large/metric_small)/(N_large/N_small)`: ~1.0 is linear, ~4.0 is the
* O(n²) shape the M1 perf review flagged for `extendBlock`'s concat chain):
* - TIME — wall-clock of `collectFunctionCfgs` (median of reps);
* - DISK — utf8 byte size of the serialized `cfgSideChannel` (what a `--pdg`
* run writes onto every ParsedFile shard);
* - MEMORY — retained JS heap of the `cfgSideChannel` payload, by the
* release-delta method (heap held minus heap after dropping it). Requires
* `node --expose-gc`; without it the heap metric is null and its gate skips.
* It also computes an order-independent sha256 fingerprint over the emitted
* blocks/edges of a fixed-size source — the correctness gate that a structural
* speedup must leave behavior-identical.
*
* Build-free: imports the `.ts` hotpaths through tsx
* (`node --expose-gc --import tsx bench/cfg/measure.mjs`). Parsing happens ONCE
* per size and the tree is reused across reps so the time measurement isolates
* CFG build cost, not tree-sitter parse time. `maxFunctionLines` is 0 (no cap)
* here on purpose — the bench measures the algorithm; the production default cap
* is a separate safety net (and would otherwise skip the large straight-line fn).
*
* Without args: prints one JSON object per scenario.
* With `--check`: asserts each scenario's fingerprint == its committed baseline
* (baselines.json) AND each of the time / disk / heap ratios is below its
* recorded budget; exits non-zero on any drift/regression.
*/
import fs from 'node:fs';
import path from 'node:path';
import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
import {
DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS,
} from '../../src/core/ingestion/cfg/emit.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { getProvider } from '../../src/core/ingestion/languages/index.ts';
import { SupportedLanguages } from '../../src/config/supported-languages.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
computeTaintFlows,
DEFAULT_PDG_MAX_TAINT_HOPS,
} from '../../src/core/ingestion/taint/propagate.ts';
import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
// ---- per-language registry (the U1 parameterization, #2195) ----
//
// A scenario names a `lang` (default 'ts'); the registry resolves its grammar,
// CFG visitor, and (optional) taint model GENERICALLY — the grammar via the
// production `getLanguageGrammar` loader and the visitor via the provider's
// `cfgVisitor` hook (the same seam `cfg-snapshot.test.ts` uses). No language is
// named in the bench logic itself: adding a language is one row here, not a new
// static grammar import (the language-naming anti-pattern). Lazy by design —
// only languages actually referenced by a scenario are loaded, so a missing
// optional grammar never breaks an unrelated run.
//
// - `grammar` — SupportedLanguages enum value for `getLanguageGrammar`.
// - `taintModel` — source/sink config threaded into the taint pass. ONLY the
// TS row carries `TS_JS_TAINT_MODEL`; C-family rows have no
// model (matching prod: `getSourceSinkConfig(<c-lang>)` is
// `undefined`), so the TS model never runs against a
// C-family CFG.
const LANGS = {
ts: { grammar: SupportedLanguages.TypeScript, taintModel: TS_JS_TAINT_MODEL },
go: { grammar: SupportedLanguages.Go, taintModel: null },
java: { grammar: SupportedLanguages.Java, taintModel: null },
c: { grammar: SupportedLanguages.C, taintModel: null },
cpp: { grammar: SupportedLanguages.CPlusPlus, taintModel: null },
csharp: { grammar: SupportedLanguages.CSharp, taintModel: null },
};
// Lazily build + cache one { parser, visitor, parse, taintModel } toolkit per
// language id. The parser is created once and reused across parses/reps for that
// language (parse cost is isolated from CFG-build cost by reusing the tree).
const langToolkitCache = new Map();
function langToolkit(langId) {
const cached = langToolkitCache.get(langId);
if (cached) return cached;
const spec = LANGS[langId];
if (!spec) throw new Error(`bench: unknown lang '${langId}' (add a row to LANGS)`);
const visitor = getProvider(spec.grammar).cfgVisitor;
if (!visitor)
throw new Error(`bench: provider for '${langId}' has no cfgVisitor (visitor not wired?)`);
const parser = new Parser();
parser.setLanguage(getLanguageGrammar(spec.grammar));
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
const toolkit = { visitor, parse, taintModel: spec.taintModel };
langToolkitCache.set(langId, toolkit);
return toolkit;
}
// ---- synthetic generators (one cost dimension each) ----
const SCENARIOS = [
{
name: 'straight-line',
// One function, N coalescing simple statements → all fold into one basic
// block whose text is accumulated statement-by-statement (extendBlock).
// Uses LARGER sizes than the other scenarios: this scenario's only cost
// dimension is text accumulation (output size is constant — 4 blocks at any
// N — so the disk/heap ratios can't see it), so the TIME ratio is the sole
// guard against an extendBlock O(n²)-concat re-regression. At small N a
// quadratic is masked by V8 cons-strings + the linear tree-walk and slips
// under the budget; these larger sizes make a real quadratic separate
// cleanly (verified: a `+=` regression here exceeds the budget, the
// array-join impl stays ~1).
small: 2000,
large: 8000,
gen: (n) => {
let s = 'function f() {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i} + 1;\n`;
return s + ' return v0;\n}\n';
},
},
{
name: 'many-functions',
// N independent small functions with a branch + return → stresses the
// tree walk in collectFunctionCfgs and the per-function build.
gen: (n) => {
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(x: number) { if (x > ${i}) { a(); } else { b(); } return x + ${i}; }\n`;
}
return s;
},
},
{
name: 'branchy',
// One function, N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and namedChildren on the body.
gen: (n) => {
let s = 'function f(x: number) {\n';
for (let i = 0; i < n; i++) s += ` if (x > ${i}) { s${i}(); }\n`;
return s + '}\n';
},
},
{
name: 'dense-bindings',
// #2082 M2: N bindings live across ~N blocks inside one loop — bindings ×
// blocks scale JOINTLY, the discriminator for solver-lattice quadratics.
// The overlay design (KTD2: sets shared by reference, OUT spine-copied
// only on gen) is expected to scale ~linearly-with-a-spine-copy here
// (normalized ratio low single digits); the regression this scenario
// exists to catch is the repo's recurring per-item-rescan shape — a
// per-use scan over all defs (O(n³) here) blows the ratio past ~16.
// rd time is the gated metric (rd_scaling_budget).
rdMaxFacts: 0, // measure the algorithm, not the cap
gen: (n) => {
let s = 'function f(c: number) {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
s += ' while (c > 0) {\n';
for (let i = 0; i < n; i++) s += ` if (c > ${i}) { v${i} = v${(i + 1) % n} + 1; }\n`;
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'deep-nest',
// #2201: N nested loops carrying one variable end-to-end — the pathology the
// dense GEN/KILL worklist is superlinear on and that drives its block-visit
// total past the blocks×64 ceiling (it would truncate to an empty result).
// The production SSA solver is depth-INDEPENDENT (φ-nodes capture the loop
// merges statically; no fixpoint iteration), so rd time scales ~linearly
// with depth and the ceiling never fires. Two gates: rd_scaling_budget
// catches a regression back to superlinear, and facts_large_min asserts the
// solver still COMPUTES full facts under the PRODUCTION blocks×64 budget
// (rdProductionBudget) — a dense worklist would report zero facts here.
small: 40,
large: 160, // 4×, well under the visitor's recursive-nesting depth guard
rdMaxFacts: 0, // measure the algorithm, not the cap
rdProductionBudget: true, // pass blocks×64 — the SSA solver must still compute
gen: (n) => {
let s = 'function f(c: number) {\n let x = 0;\n';
for (let i = 0; i < n; i++) s += ' '.repeat(i + 1) + `while (c > ${i}) {\n`;
s += ' '.repeat(n + 1) + 'x = x + 1;\n';
for (let i = n - 1; i >= 0; i--) s += ' '.repeat(i + 1) + '}\n';
return s + ' return x;\n}\n';
},
},
{
name: 'wide-merge',
// #2201 review R7: N bindings, each assigned in a 3-way branch (a WIDE φ
// merge per binding) inside a loop, then all used after the merge. Unlike
// dense-bindings (one chained redef per `if`), every binding here fans into
// its own multi-operand φ — so the scenario stresses φ-placement + renaming +
// the reachByScc condensation across MANY independent wide merges. N bindings
// × constant arms ⇒ O(N) facts, so the gate is rd_scaling LINEARITY: a
// regression to the per-binding-rescan class (O(N²), the recurring solver
// antipattern reachByScc's alias fast path guards against) blows the ratio.
// >=16 blocks + a reachable loop ⇒ the production SSA path.
rdMaxFacts: 0, // measure the algorithm, not the cap
rdProductionBudget: true, // prove the SSA path computes under blocks×64
gen: (n) => {
let s = 'function f(c: number) {\n';
for (let i = 0; i < n; i++) s += ` let v${i} = ${i};\n`;
s += ' while (c > 0) {\n';
for (let i = 0; i < n; i++) {
s +=
` if (c > ${i}) { v${i} = ${i} + c; }` +
` else if (c < ${i}) { v${i} = ${i} - c; }` +
` else { v${i} = c; }\n`;
}
for (let i = 0; i < n; i++) s += ` use(v${i});\n`;
return s + ' c = c - 1;\n }\n return v0;\n}\n';
},
},
{
name: 'fact-fanout',
// #2082 M2: N parallel case-arm defs of one variable + N later uses —
// facts are O(defs×uses) BY SPEC, so a linearity ratio gate is the wrong
// shape. The gate here is BOUNDEDNESS: with the production fact limit
// engaged, the materialized fact count stays FLAT (== limit) as N grows
// past it (facts_large_max), and rd time stays bounded. An unbounded
// materialization regression (losing the maxFacts early-stop) shows as
// facts_large exploding quadratically.
rdMaxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
gen: (n) => {
let s = 'function f(c: number) {\n let x = 0;\n switch (c) {\n';
for (let i = 0; i < n; i++) s += ` case ${i}: x = ${i}; break;\n`;
s += ' }\n';
for (let i = 0; i < n; i++) s += ` u${i}(x);\n`;
return s + '}\n';
},
},
{
name: 'taint-dense',
// #2083 M3 U7 (R10): N functions, EACH source/sink-dense — 12 matched
// `req.body` source statements + a 4-hop chained reassignment + 13 `eval`
// sinks per function (13 deduped findings/fn, ABOVE the scenario cap of 8
// so the cap binds). Functions scale with N, so total findings grow
// linearly BY DESIGN; the boundedness gate is the per-function pin: kept
// findings/function stays EXACTLY at the cap as N grows (a cap loss shows
// as 13). This scenario's sites are the densest of the suite, so its
// ABSOLUTE disk_bytes_large_max is the load-bearing site-harvest ceiling
// (the M2 straight-line carrier has no call sites), and the summed
// encoded TAINTED reason bytes get their own absolute ceiling
// (taint_reason_bytes_large_max). The zero-match control (genZero) keeps
// the identical statement/CFG shape with names OUTSIDE the model
// (inp.payload / evalish) — the match-gate must make unmatched functions
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
lang: 'ts', // taint model is TS-only; never run TS_JS_TAINT_MODEL on a C-family CFG
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
{
name: 'go:branchy',
// #2195 U7: the first NON-TS scaling scenario — the C-family analogue of the
// TS `branchy` stressor, run through the Go grammar + Go CFG visitor. ONE Go
// function with N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and the namedChildren walk on the
// Go body. The `go:` namespace keys it out of the TS baseline keyspace so a
// C-family entry can never collide with (or silently re-baseline) a TS
// scenario. CFG-only (Go has no registered taint model — see LANGS), so the
// gated metrics are the time/disk/heap/rd scaling ratios + the fingerprint.
lang: 'go',
gen: (n) => {
let s = 'package p\nfunc f(x int) {\n';
for (let i = 0; i < n; i++) s += `\tif x > ${i} {\n\t\ts${i}()\n\t}\n`;
return s + '}\n';
},
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
// unmatched one without changing statement count, def/use shape, or CFG.
const TAINT_SOURCES_PER_FN = 12;
const TAINT_CHAIN_HOPS = 4;
function genTaintFunctions(n, zero) {
const recv = zero ? 'inp' : 'req';
const prop = zero ? 'payload' : 'body';
const sink = zero ? 'evalish' : 'eval';
let s = '';
for (let i = 0; i < n; i++) {
s += `function f${i}(${recv}) {\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` const s${j} = ${recv}.${prop};\n`;
s += ` let c0 = s0 + '!';\n`;
for (let h = 1; h < TAINT_CHAIN_HOPS; h++) s += ` const c${h} = c${h - 1} + '!';\n`;
for (let j = 0; j < TAINT_SOURCES_PER_FN; j++) s += ` ${sink}(s${j});\n`;
s += ` ${sink}(c${TAINT_CHAIN_HOPS - 1});\n`;
s += '}\n';
}
return s;
}
const SMALL = 500;
const LARGE = 2000; // 4× — O(n) ⇒ ratio ~1, O(n²) ⇒ ratio ~4
const REPS = 15; // median over more reps → stabler time signal at small absolute ms
const FP_SIZE = 15; // fixed size for the behavior fingerprint
const NO_CAP = 0; // measure the algorithm, not the production safety cap
// ---- timing ----
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = Math.floor(s.length / 2);
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(tk, src, file, reps) {
const root = tk.parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, tk.visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, tk.visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
ms: median(samples),
cfgs: out.cfgs,
blockCount: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
// DISK growth: utf8 byte size of the serialized cfgSideChannel — exactly
// what a --pdg run writes onto every ParsedFile shard in the durable store
// + parse cache (the field is plain JSON, so this is the on-disk delta).
// Should scale linearly with source covered; a super-linear ratio means the
// CFG duplicates text and bloats warm-cache shards at scale.
diskBytes: Buffer.byteLength(JSON.stringify(out.cfgs), 'utf8'),
};
}
// ---- reaching-defs solve cost (#2082 M2) ----
// Times computeReachingDefs over a scenario's collected CFGs (the exact work
// the scope-resolution emit loop adds per file on a --pdg run). `maxFacts`
// mirrors the per-scenario production posture: 0 (unlimited) measures the
// algorithm; the production default exercises the boundedness contract.
// When `blockVisitsMul` > 0 each call also passes the PRODUCTION per-function
// maxBlockVisits budget (blocks × mul). On the deep-nest scenario this is how
// "the ceiling stops firing" (#2201) is measured: the dense worklist would
// truncate to an empty result under this budget, whereas the production SSA
// solver computes the full facts — so a nonzero `facts` under the budget is the
// gate (see facts_large_min in baselines.json).
function measureReachingDefs(cfgs, reps, maxFacts, blockVisitsMul = 0) {
const limitsFor = (c) =>
blockVisitsMul > 0
? { maxFacts, maxBlockVisits: c.blocks.length * blockVisitsMul }
: { maxFacts };
for (const c of cfgs) computeReachingDefs(c, limitsFor(c)); // warm JIT
const samples = [];
let facts = 0;
let allComputed = true;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
facts = 0;
for (const c of cfgs) {
const r = computeReachingDefs(c, limitsFor(c));
facts += r.facts.length;
if (r.status !== 'computed') allComputed = false;
}
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), facts, allComputed };
}
// ---- taint pass cost (#2083 M3 U7) ----
// Times the EXACT per-function sequence the in-phase emit driver runs on a
// --pdg run for a taint-modeled language: match sites → zero-match fast path
// → computeReachingDefs → computeTaintFlows. `cap` is the scenario's
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap, taintModel) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
let kept = 0;
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, taintModel, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
});
const flows = computeTaintFlows(c, du, matches, {
maxFindingsPerFunction: cap,
maxHops: DEFAULT_PDG_MAX_TAINT_HOPS,
});
if (flows.status !== 'computed') continue;
analyzed++;
kept += flows.findings.length;
dropped += flows.droppedFindings;
for (const f of flows.findings) {
// All structural chars + identifier names are single-byte ASCII, so
// string length IS the byte length (path-codec discipline).
reasonBytes += encodeTaintPath(
f.hops.map((h) => ({ name: h.name, line: h.point.line, viaCall: h.viaCall })),
{ truncated: f.hopsTruncated === true, kind: f.sinkKind },
).reason.length;
}
}
return { analyzed, kept, dropped, reasonBytes };
};
pass(); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = pass();
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return { ms: median(samples), ...out };
}
// ---- memory growth: retained heap of the cfgSideChannel payload ----
// Needs `node --expose-gc` to force collection for a clean delta; without it the
// heap metric is reported as null and its --check gate is skipped (so a local
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(tk, src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
// payload retains (the extra RAM a --pdg run carries per file until the shard
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(tk.parse(src).rootNode, tk.visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
cfgs = null;
GC();
const withoutCfgs = process.memoryUsage().heapUsed;
return Math.max(0, withCfgs - withoutCfgs);
}
// ---- correctness fingerprint (order-independent over blocks + edges) ----
function canonicalizeCfg(cfg) {
const blocks = cfg.blocks
.map(
(b) =>
`B|${b.index}|${b.startLine}-${b.endLine}|${b.kind}|${b.text}|` +
// #2082 M2: statement facts join the canon so harvest drift (lost
// defs/uses, changed binding resolution) trips the fingerprint gate.
JSON.stringify(b.statements ?? null),
)
.sort();
const edges = cfg.edges.map((e) => `E|${e.from}->${e.to}|${e.kind}`).sort();
const bindings = JSON.stringify(cfg.bindings ?? null);
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(tk, scenario) {
const out = collectFunctionCfgs(
tk.parse(scenario.gen(FP_SIZE)).rootNode,
tk.visitor,
'fp',
NO_CAP,
);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
fp_cfgs: out.cfgs.length,
fp_blocks: out.cfgs.reduce((a, c) => a + c.blocks.length, 0),
fp_edges: out.cfgs.reduce((a, c) => a + c.edges.length, 0),
};
}
function measureScenario(scenario) {
// Resolve the scenario's language toolkit ONCE (default 'ts' keeps every
// pre-existing TS scenario on the exact same grammar+visitor+model path it
// used before the U1 parameterization → byte-identical baselines).
const tk = langToolkit(scenario.lang ?? 'ts');
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(tk, scenario.gen(nSmall), `${scenario.name}.src`, REPS);
const large = measureCollect(tk, scenario.gen(nLarge), `${scenario.name}.src`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(tk, scenario.gen(nSmall), `${scenario.name}.src`);
const heapLarge = retainedHeapBytes(tk, scenario.gen(nLarge), `${scenario.name}.src`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
: null;
// #2082 M2: reaching-defs solve cost over the same CFGs. #2201: scenarios
// marked `rdProductionBudget` also pass the per-function blocks×64 ceiling, to
// prove the production SSA solver still COMPUTES where the dense worklist would
// truncate (the deep-nest ceiling-stops-firing acceptance).
const rdMaxFacts = scenario.rdMaxFacts ?? 0;
const rdBudgetMul = scenario.rdProductionBudget ? DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS : 0;
const rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts, rdBudgetMul);
const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts, rdBudgetMul);
// Clamp the denominator: a 0.000ms small-N median would otherwise yield
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios. The
// taint model is the scenario's language model (TS_JS_TAINT_MODEL for the TS
// taint-dense scenario; a taint scenario requires a model-bearing language).
let taintMetrics = {};
if (scenario.taint !== undefined) {
if (!tk.taintModel)
throw new Error(
`bench: scenario '${scenario.name}' has a taint config but lang '${scenario.lang ?? 'ts'}' has no taint model`,
);
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap, tk.taintModel);
const tLarge = measureTaint(large.cfgs, REPS, cap, tk.taintModel);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
tk.parse(scenario.genZero(nLarge)).rootNode,
tk.visitor,
`${scenario.name}-zero.src`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap, tk.taintModel);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
taint_scaling_ratio: Number(tRatio.toFixed(3)),
// Boundedness: kept findings PER ANALYZED FUNCTION (total findings grow
// linearly with N by design — the per-function pin is the cap gate).
taint_findings_per_fn_small: tSmall.analyzed > 0 ? tSmall.kept / tSmall.analyzed : 0,
taint_findings_per_fn_large: tLarge.analyzed > 0 ? tLarge.kept / tLarge.analyzed : 0,
taint_dropped_large: tLarge.dropped,
taint_reason_bytes_large: tLarge.reasonBytes,
taint_zero_ms_large: Number(tZero.ms.toFixed(3)),
taint_zero_findings: tZero.kept + tZero.dropped,
taint_zero_match_ratio: Number((tZero.ms / Math.max(tLarge.ms, 0.001)).toFixed(3)),
};
}
return {
...taintMetrics,
scenario: scenario.name,
elapsed_ms_small: Number(small.ms.toFixed(3)),
elapsed_ms_large: Number(large.ms.toFixed(3)),
scaling_ratio: Number(scalingRatio.toFixed(3)),
disk_bytes_small: small.diskBytes,
disk_bytes_large: large.diskBytes,
disk_bytes_ratio: Number(diskRatio.toFixed(3)),
heap_bytes_small: heapSmall,
heap_bytes_large: heapLarge,
heap_ratio: heapRatio === null ? null : Number(heapRatio.toFixed(3)),
blocks_small: small.blockCount,
blocks_large: large.blockCount,
rd_ms_small: Number(rdSmall.ms.toFixed(3)),
rd_ms_large: Number(rdLarge.ms.toFixed(3)),
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
facts_small: rdSmall.facts,
facts_large: rdLarge.facts,
rd_all_computed: rdLarge.allComputed,
...fingerprint(tk, scenario),
};
}
// ---- run ----
const CHECK = process.argv.includes('--check');
// The retained-heap budget is a primary regression detector, but it can only be
// measured with a forced GC. Rather than let `--check` silently PASS with the
// heap gate skipped (a green no-op if someone drops --expose-gc), fail loudly.
if (CHECK && !GC) {
process.stderr.write(
'[cfg --check] FAIL: retained-heap gate requires --expose-gc. ' +
'Run: node --expose-gc --import tsx bench/cfg/measure.mjs --check\n',
);
process.exit(1);
}
const results = SCENARIOS.map(measureScenario);
if (!CHECK) {
for (const r of results) process.stdout.write(JSON.stringify(r) + '\n');
} else {
const baselines = JSON.parse(fs.readFileSync(BASELINE_PATH, 'utf8'));
const failures = [];
for (const r of results) {
const base = baselines[r.scenario];
if (base === undefined) {
failures.push(`${r.scenario}: no baseline recorded`);
continue;
}
if (r.fingerprint !== base.fingerprint) {
failures.push(
`${r.scenario}: CFG fingerprint drift (got ${r.fingerprint}, expected ${base.fingerprint})`,
);
}
if (r.scaling_ratio >= base.scaling_budget) {
failures.push(
`${r.scenario}: scaling ratio ${r.scaling_ratio} >= budget ${base.scaling_budget} ` +
`(${SMALL}->${LARGE} stmts/fns, ms ${r.elapsed_ms_small}->${r.elapsed_ms_large})`,
);
}
if (base.disk_bytes_budget !== undefined && r.disk_bytes_ratio >= base.disk_bytes_budget) {
failures.push(
`${r.scenario}: cfgSideChannel disk-bytes ratio ${r.disk_bytes_ratio} >= budget ` +
`${base.disk_bytes_budget} (bytes ${r.disk_bytes_small}->${r.disk_bytes_large})`,
);
}
// #2082 M2 gates — rd solve-time scaling, fact-count boundedness, and an
// ABSOLUTE side-channel size ceiling (a ratio gate is blind to a
// constant-factor encoding bloat like named records vs indexed facts).
if (base.rd_scaling_budget !== undefined && r.rd_scaling_ratio >= base.rd_scaling_budget) {
failures.push(
`${r.scenario}: reaching-defs scaling ratio ${r.rd_scaling_ratio} >= budget ` +
`${base.rd_scaling_budget} (ms ${r.rd_ms_small}->${r.rd_ms_large})`,
);
}
if (base.facts_large_max !== undefined && r.facts_large > base.facts_large_max) {
failures.push(
`${r.scenario}: fact materialization ${r.facts_large} > bound ${base.facts_large_max} ` +
`(the maxFacts early-stop is the boundedness contract)`,
);
}
// #2201 deep-nest: under the PRODUCTION blocks×64 budget the SSA solver must
// still COMPUTE full facts (a nonzero floor) where the dense worklist would
// truncate to empty — "the ceiling stops firing".
if (base.facts_large_min !== undefined && r.facts_large < base.facts_large_min) {
failures.push(
`${r.scenario}: only ${r.facts_large} facts < floor ${base.facts_large_min} under the ` +
`production block-visit budget — the ceiling fired (SSA should not truncate here)` +
(r.rd_all_computed ? '' : ` [status != computed]`),
);
}
// Independent of the fact-count floor: under the production budget every
// function in a facts_large_min scenario must report status 'computed'. This
// catches a partial-truncation regression that still clears the count floor.
if (base.facts_large_min !== undefined && r.rd_all_computed === false) {
failures.push(
`${r.scenario}: a function did not reach status 'computed' under the production ` +
`block-visit budget — the SSA solver truncated where it must compute`,
);
}
if (base.disk_bytes_large_max !== undefined && r.disk_bytes_large > base.disk_bytes_large_max) {
failures.push(
`${r.scenario}: cfgSideChannel absolute size ${r.disk_bytes_large} > ceiling ` +
`${base.disk_bytes_large_max} bytes (constant-factor encoding bloat)`,
);
}
// #2083 M3 U7 gates — taint boundedness (per-function findings pinned at
// the cap as N grows), an ABSOLUTE ceiling on persisted TAINTED reason
// bytes, taint solve-time scaling, and the zero-match fast path staying
// ~free relative to the match-dense pass.
if (base.taint_findings_per_fn_pin !== undefined) {
for (const side of ['small', 'large']) {
const perFn = r[`taint_findings_per_fn_${side}`];
if (perFn !== base.taint_findings_per_fn_pin) {
failures.push(
`${r.scenario}: taint findings/function (${side}) ${perFn} != pin ` +
`${base.taint_findings_per_fn_pin} (cap must BIND exactly: above = cap lost, ` +
`below = detection regressed)`,
);
}
}
if (r.taint_zero_findings !== 0) {
failures.push(
`${r.scenario}: zero-match control produced ${r.taint_zero_findings} findings ` +
`(the control must not match the model — generator drift)`,
);
}
}
if (
base.taint_reason_bytes_large_max !== undefined &&
r.taint_reason_bytes_large > base.taint_reason_bytes_large_max
) {
failures.push(
`${r.scenario}: persisted TAINTED reason bytes ${r.taint_reason_bytes_large} > ceiling ` +
`${base.taint_reason_bytes_large_max} (hop-encoding bloat or cap loss)`,
);
}
if (
base.taint_scaling_budget !== undefined &&
r.taint_scaling_ratio >= base.taint_scaling_budget
) {
failures.push(
`${r.scenario}: taint scaling ratio ${r.taint_scaling_ratio} >= budget ` +
`${base.taint_scaling_budget} (ms ${r.taint_ms_small}->${r.taint_ms_large})`,
);
}
if (
base.taint_zero_match_budget !== undefined &&
r.taint_zero_match_ratio >= base.taint_zero_match_budget
) {
failures.push(
`${r.scenario}: zero-match taint time is ${r.taint_zero_match_ratio} of the match-dense ` +
`pass, >= budget ${base.taint_zero_match_budget} (the match gate must keep unmatched ` +
`functions ~free — no solver call)`,
);
}
// Heap gate only when measured (--expose-gc present) AND a budget exists.
if (
base.heap_budget !== undefined &&
r.heap_ratio !== null &&
r.heap_ratio >= base.heap_budget
) {
failures.push(
`${r.scenario}: retained-heap ratio ${r.heap_ratio} >= budget ${base.heap_budget} ` +
`(heap ${r.heap_bytes_small}->${r.heap_bytes_large})`,
);
}
process.stdout.write(JSON.stringify(r) + '\n');
}
if (failures.length > 0) {
for (const f of failures) process.stderr.write(`[cfg --check] FAIL: ${f}\n`);
process.exit(1);
}
process.stderr.write(`[cfg --check] PASS (${results.length} scenarios)\n`);
}