From 6932e7a9fdfa4fe56eba1001c3c3aa159df8d267 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 15 Jun 2026 12:31:04 +0100 Subject: [PATCH 1/3] feat(cfg): PDG/CFG visitors for all supported languages (#2195) (#2197) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * test(cfg): validate cfg/visitors literals + drop 3 dead TS node types Extend the grammar-literal CI gate (test/helpers/literal-collectors.ts) to scan cfg/visitors/*.ts, mapping each visitor file to its grammar via the existing basename rule (c-cpp -> C/C++, csharp -> C#, java -> Java, go -> Go, typescript -> TS). Closes the gap where the gate never validated CFG visitor node-type literals -- the prerequisite for adding C-family visitors safely (#2195 U1). The newly-scanned TS visitor surfaced 3 dead literals absent from every grammar it serves (typescript/javascript/tsx all = 0): for_of_statement (for-of parses as for_in_statement), async_function_declaration and async_arrow_function (async functions are function_declaration / arrow_function + an async child). Removed them; behavior-preserving -- the cases never matched, bench --check fingerprints unchanged, TS visitor unit tests green. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): language-agnostic CFG unit-test harness (#2195 U1) Extract the grammar-agnostic engine from ts-cfg-harness into makeCfgHarness(grammar, visitor, filePath) at test/helpers/cfg-harness.ts. Function discovery delegates to visitor.isFunction, so the harness carries no language-specific node-type knowledge -- each C-family visitor's unit tests can drive the real worker-side builder against real source. ts-cfg-harness becomes a thin TS binding re-exporting the same parse/collectFunctions/cfgOf/cfgsOf (behavior-preserving: all 5 existing consumers -- taint propagate/model-match/summary-harvest/taint-emit + cfg harvest -- pass unchanged, 223 tests green). New harness.test.ts proves TS-faithfulness and isFunction-delegation via a stub visitor. The bench parameterization (measure.mjs) is sequenced into U7, where the first C-family scaling scenario makes the {grammar, visitorFactory} seam validatable against a real non-TS language. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): C and C++ CFG visitor + def/use harvest (#2195 U2) Add createCCfgVisitor/createCppCfgVisitor over a shared CCfgWalk core. Grammar introspection confirmed tree-sitter-c and tree-sitter-cpp share every control-flow node type/field, so CppCfgWalk extends CCfgWalk with only the C++-only nodes (try/catch/throw/for_range_loop/lambda) via a visitExtra hook -- no language conditionals (AGENTS no-language-naming). Wire both into c-cpp.ts providers. Harvest (c-cpp-harvest.ts): two-phase binding table + per-statement defs/uses/mayDefs (no sites[] yet -- U6). Edge kinds match the TS contract; functionStartColumn populated; non-terminating loops (for(;;), while(1)) emit the structural exit-escape edge so EXIT stays reverse-reachable and CDG is not silently skipped -- verified against the production post-dominator + control-dependence solvers (for(;;) -> 3 CDG edges). buildFunctionCfg returns undefined rather than throwing. 23 real-parser regression tests; grammar-literal gate green (literals validated against both grammars). Documented gaps: C++ RAII destructors, setjmp/longjmp, computed goto (route to EXIT + warn). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): C# CFG visitor + def/use harvest (#2195 U3) Add createCsharpCfgVisitor + csharp-harvest over the shared CfgBuilder / ControlFlowContext, modeling the C# statement taxonomy: if/else, for/foreach/while/do, switch_section (+ switch_expression arms), try/catch/catch_filter/finally, using + lock (deterministic finalizers -- dispose/release runs on normal AND exception exit, finally-* completion edges on crossing jumps), goto/labeled, yield (surface only), return/ throw/break/continue. Wire into csharpProvider. Every literal validated against tree-sitter-c-sharp via the introspection probe (record_declaration, no else_clause, switch_section, positional access where no field exists). Edge kinds match the contract; functionStartColumn populated; while(true) keeps EXIT reverse-reachable (production CDG probe: 3 edges). buildFunctionCfg returns undefined rather than throwing. 34 real-parser regression tests; grammar-literal gate green; no regression (cfg unit dir 256/256, tsc clean). Documented gaps: yield iterator state machine, goto case/default, async suspension points. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Java CFG visitor + def/use harvest (#2195 U4) Add createJavaCfgVisitor + java-harvest over the shared CfgBuilder / ControlFlowContext: if/else, classic for, enhanced-for, while, do-while, classic-vs-arrow switch (switch_block_statement_group fallthrough vs switch_rule no-fallthrough), try/catch/finally + try-with-resources (auto-close synthesized as a finalizer, closes on normal AND exception exit) + synchronized (monitor-release finalizer), labeled break/continue to the labeled frame, yield, return/throw/break/continue. Wire into javaProvider. Every literal validated against tree-sitter-java via the probe (switch_expression covers both switch forms, generic_type, line_comment, for init field). Edge kinds match the contract; functionStartColumn populated; while(true)/for(;;) keep EXIT reverse-reachable (production CDG probe: 3 edges; hazard fixture: 34 CDG edges). buildFunctionCfg returns undefined rather than throwing. 43 real-parser regression tests; grammar-literal gate green; no regression (cfg unit suite 304, tsc clean). Documented gaps: switch-as- expression-value inline, yield state machine, async/field-write defs. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Go CFG visitor + def/use harvest (#2195 U5) Add createGoCfgVisitor + go-harvest, the highest-divergence target: for_statement (all four shapes -- for_clause C-style, while-style, range_clause, bare for{}), expression/type switch (no implicit fallthrough) + explicit fallthrough_statement, select_statement, defer (LIFO finalizer legs at function exit), go (call is straight-line; the closure body is its own CFG via isFunction), labeled break/continue/goto, multiple-return assigns (a, b := f() defines each LHS). Wire into goProvider. CRITICAL (review A2): every non-terminating shape -- for{}, for cond{}, select{} with no default -- emits a structural exit-escape edge so EXIT stays reverse-reachable and the production CDG is not silently skipped. Verified: for{} -> CDG=3, select{} -> CDG=1, for-range -> CDG=2, all exitReachable=true. Every literal validated against tree-sitter-go via the probe. 32 real-parser regression tests; grammar-literal gate green; no regression (186 across all 5 visitors + gate, full cfg unit 331, tsc clean). Documented gaps: panic/recover unwind, goroutine happens-before. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): call-site sites[] taint substrate for C-family (#2195 U6) Extend the C/C++/C#/Java/Go harvests with the call-site sites[] taint substrate (SiteRecord/SiteArgOccurrence), mirroring the TS shape so the shared taint matcher consumes all languages uniformly. Extract the grammar-agnostic site machinery into cfg/visitors/call-site-harvest.ts (CallSiteFactAccumulator -- names no language); each harvest adds only its per-grammar visitCall/walkChain over its call node (C/C++ call_expression, C# invocation_expression, Java method_invocation, Go call_expression). INERT BY DESIGN: no C-family taint model exists (registerBuiltinTaintModels is TS/JS only), so getSourceSinkConfig returns undefined for these languages and the harvested sites produce ZERO TAINTED edges -- the positive source->sink->TAINTED path is deferred with the model authoring. sites emitted only when non-empty; facts-only attachment, block/edge topology unchanged (pre-existing topology + def/use tests byte-identical). 23 new substrate tests; 574 green across the cfg/taint/emit suites; gate green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): worker-mode PDG integration + bench parameterization (#2195 U7) Prove the five C-family visitors build PDG through the REAL worker pipeline. pipeline-pdg.test.ts: per-language (C/C++/C#/Java/Go) temp repo run with pdg:true asserts BasicBlock+CFG+REACHING_DEF+CDG all > 0 (CDG>0 proves EXIT stays reverse-reachable end-to-end through the worker, incl. each fixture's non-terminating loop/select); a paired run with pdg off asserts == 0, the two flag-off graphs byte-identical (R3), no PDG types leak, pinned by a golden snapshot. Counts e.g. Go 151 BB / 56 CDG. Parameterize bench/cfg/measure.mjs by a per-language LANGS registry resolved generically via getLanguageGrammar + getProvider(X).cfgVisitor (no static import table). Default TS byte-identical -- all 6 TS fingerprints unchanged under --check; taint-dense stays TS-only (TS_JS_TAINT_MODEL never runs against model-less C-family CFGs). Add a go:branchy scenario+baseline (namespaced) -- its fingerprint shape (32 blocks/46 edges) matches TS branchy, cross-validating the Go visitor. 15 pipeline tests + bench --check PASS (7 scenarios); 354 unit cfg green; dist rebuilt clean. Absorbs the bench parameterization deferred from U1. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Python CFG visitor + def/use harvest (#2195 U8) Add createPythonCfgVisitor + python-harvest -- the most structurally divergent target (indentation blocks, elif, for/while-else, with, try/ except/except-group/else/finally, match/case, comprehensions, walrus), confirming the shared CfgBuilder/ControlFlowContext core carries no brace-family assumptions. for/while else-clause sits on the normal- completion edge (not break); with modeled as try/finally dispose; match has no fallthrough. Wire into pythonProvider. Every literal validated against tree-sitter-python via the probe. while True: keeps EXIT reverse-reachable (production CDG probe: 3 edges; fixture: 42 CDG edges). 37 real-parser tests; gate green; no regression (cfg unit 391, tsc clean). Gaps: async/generator suspension, comprehension scope over-approximation. No sites[] (taint substrate, separate). Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): PHP CFG visitor + def/use harvest (#2195 U9) Add createPhpCfgVisitor + php-harvest: if/elseif/else (+ alt colon syntax), for/foreach/while/do-while, switch (fallthrough) + match (no fallthrough), try/catch/finally, break N/continue N (N-th enclosing loop), goto, return/throw. Wire into phpProvider. Every literal validated against tree-sitter-php (php_only) via the probe (for_statement initialize/condition/update; throw_expression not throw_statement; break/continue integer child). while(true) keeps EXIT reverse-reachable (production CDG probe: 3 edges; break 2 escapes the outer loop). 35 real-parser tests. Also repoint worker-roundtrip's "non-CFG language" gate test from Python (which now has a cfgVisitor) to COBOL (the permanent non-goal of the rollout) -- a stale assertion the Python commit invalidated. Full in-process sweep green (452 across 18 files). Gaps: match inline value, goto plain-block. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Ruby CFG visitor + def/use harvest (#2195 U10) Add createRubyCfgVisitor + ruby-harvest: if/unless/elsif/else + statement-modifier forms (x if c, x while c), while/until/for (until inverts the sense), case/when + case/in (pattern, no fallthrough), begin/rescue/else/ensure (ensure=finally, rescue=catch) + retry (loop-back into begin), return/break/next/redo, blocks/lambdas as their own closure CFGs. Wire into rubyProvider. Every literal validated against tree-sitter-ruby via the probe (case vs case_match, modifier nodes, typed rescue/ensure children). loop do / while true keep EXIT reverse-reachable (production CDG probe: 3 edges). 34 real-parser tests; comprehensive sweep green (486). Gaps: yield, expression-position if/case/begin inline, ivar/gvar non-local defs. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Rust CFG visitor + def/use harvest (#2195 U11) Add createRustCfgVisitor + rust-harvest for the expression-oriented Rust: if/else + if-let, loop (infinite -- structural escape edge), while/ while-let/for, match (no fallthrough) + guards, labeled break/continue ('outer), break-with-value, ? operator (try_expression) as an early-return throw edge to EXIT, let-else (diverging else). visitLet handles control-flow in value position (let x = loop/if/match). Wire into rustProvider. Every literal validated against tree-sitter-rust via the probe (label is a named child not a field; line_comment; _ pattern). loop {} keeps EXIT reverse-reachable (production CDG probe: 3 edges). 33 real-parser tests; comprehensive sweep green (519). Gaps: panic, async/.await, macro bodies. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Swift CFG visitor + def/use harvest (#2195 U12) Add createSwiftCfgVisitor + swift-harvest (vendored tree-sitter-swift via requireVendoredGrammar): if/else + optional binding (if let), guard...else (diverging early exit), for-in/while/repeat-while (bottom-test), switch (no implicit fallthrough; explicit fallthrough keyword; where guards), do/catch + try/try?/try!, defer (LIFO finalizer at scope exit), labeled break/continue, control_transfer_statement (one node for break/continue/ return/throw). Wire into swiftProvider. Every literal validated against the vendored grammar via the probe (no block node; if-let folds into condition+bound_identifier; defer parses as a call_expression with trailing closure). while true keeps EXIT reverse-reachable (production CDG probe: 3 edges). 24 real-parser tests; comprehensive sweep green (543). Gaps: computed properties, defer block-scope approx, fatalError traps. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Kotlin CFG visitor + def/use harvest (#2195 U13) Add createKotlinCfgVisitor + kotlin-harvest (vendored tree-sitter-kotlin): if/else, when (subject + subjectless, no fallthrough), for/while/do-while, try/catch/finally, jump_expression (return/return@/break/break@/continue/ continue@/throw), labeled loops, control_structure_body unwrapping, expression-body functions. The grammar is field-less for control flow, so the visitor navigates by child type+position. Wire into kotlinProvider. Every literal validated against the vendored grammar via the probe (line_comment/multiline_comment, not comment). while (true) keeps EXIT reverse-reachable (production CDG probe: 3 edges; worker-mode fixture: BB=82, CDG=41). 28 real-parser tests; comprehensive sweep green (571). Gaps: value-position if/when/try inline, inline-fun non-local return, getters/setters. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Dart CFG visitor + def/use harvest (#2195 U14) Add createDartCfgVisitor + dart-harvest (vendored tree-sitter-dart): if/else, C-for/for-in/while/do-while, switch (empty-case fallthrough + explicit continue-label) + switch_expression, try/on/catch/finally + rethrow + assert (throw edges), return/break/continue/throw, labeled loops, arrow bodies, closures. Dart splits a function into sibling signature + function_body nodes, so the body (or function_expression) is the CFG-bearing node. Wire into dartProvider. Every literal validated against the vendored grammar via the probe (only constant_pattern exists; removed speculative relational/logical pattern names). while (true) keeps EXIT reverse-reachable (production CDG probe: 3 edges). 34 real-parser tests; comprehensive sweep green (605). Gaps: labeled-loop grammar quirk (read via ERROR sibling), async straight-line, value-position if/switch inline. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): Vue (reuse TS visitor) + worker-mode proof for all langs (#2195 U15) Vue SFC diff --git a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts index 2177c761a..00f4ed8f7 100644 --- a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts +++ b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts @@ -2,10 +2,13 @@ import { describe, it, expect, afterAll } from 'vitest'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import crypto from 'crypto'; import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js'; import type { PipelineResult } from '../../../src/types/pipeline.js'; import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js'; import { fixtureTaintTotals } from '../../helpers/taint-fixture.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; // U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse // worker builds a per-function CFG (workerData.pdg) and scope-resolution emits @@ -187,3 +190,356 @@ describe('U7 — end-to-end --pdg pipeline', () => { expect(cdg).toBe(0); }, 60000); }); + +// ── C-family worker-mode PDG (#2195 U7) ───────────────────────────────────── +// +// The same both-sinks proof as the TS block above, run through the REAL worker +// pipeline for each of C, C++, C#, Java, Go. Each language gets its own tiny +// repo (one hazard fixture with real branching AND a non-terminating +// loop/`select`) and we assert, under `--pdg`: +// - BasicBlock + CFG > 0 (the worker built a per-function CFG and emit wired it) +// - REACHING_DEF > 0 (the def/use harvest populates the data-dependence layer) +// - CDG > 0 AND ≥1 CDG edge is sourced INSIDE the non-terminating-loop +// function itself (`hazard`, below) — not merely an aggregate satisfied by +// any branching function in the fixture. This is the load-bearing claim: +// the post-dom/CDG pass was NOT skipped for the function whose loop traps +// EXIT, i.e. EXIT stays reverse-reachable end-to-end through the worker even +// with the non-terminating loop/`select`. (#2197 U3 — the prior whole- +// fixture `cdg > 0` aggregate did not isolate the hazard function.) +// and without `--pdg` (both the default run and an explicit `pdg:false` run): +// - BasicBlock + CFG + REACHING_DEF + CDG == 0 +// - the non-PDG graph is byte-identical between the two flag-off runs and +// matches a committed digest snapshot (the per-language byte-identical-off +// golden parity gate — R3; the cross-repo gate is pipeline-graph-golden). +// +// ⚠ Requires a FRESH `dist/parse-worker.js` — CFGs are built in the worker from +// `dist/`. A stale bundle silently zeros CFG output. `pretest:integration` (and +// the U7 verification recipe) run `node scripts/build.js` first. + +const C_FAMILY_FIXTURES = path.join(__dirname, 'fixtures'); + +// `hazard`: a substring of a BasicBlock's `text` that appears ONLY inside the +// fixture's non-terminating-loop function (`for(;;)` / `while(true)` / `for{}`). +// It locates that function's block anchor so the CDG assertion can prove the +// function specifically is CDG-bearing (see `cdgSourcedInHazardFunction`). C# +// has no such loop (its `Retry` goto-cycle is conditional and terminates), so +// it has no `hazard` and keeps the whole-fixture aggregate only. +const C_FAMILY: ReadonlyArray<{ lang: string; fixture: string; hazard?: string }> = [ + { lang: 'C', fixture: 'c-hazards.c', hazard: 'handle_request' }, // server_forever: for(;;) + { lang: 'C++', fixture: 'cpp-hazards.cpp', hazard: 'poll(' }, // run_forever: while(true) + { lang: 'C#', fixture: 'csharp-hazards.cs' }, // no non-terminating loop in the fixture + { lang: 'Java', fixture: 'java-hazards.java', hazard: 'ready(' }, // serve: while(true) + { lang: 'Go', fixture: 'go-hazards.go', hazard: 'handle(v)' }, // forInfinite: for{} +]; + +// ── Remaining-language worker-mode PDG (#2195 capstone) ───────────────────── +// +// The same both-sinks worker proof, run for the eight languages whose CFG +// visitors completed the PDG-language rollout AFTER the C-family: the dynamic +// languages (Python, PHP, Ruby), the systems/app languages (Rust, Swift, +// Kotlin, Dart), AND Vue (whose provider reuses the TypeScript CfgVisitor — the +// .vue file routes through the worker's Vue→TypeScript grammar mapping and the +// SFC +`; + const cfgs = cfgsOfSfc(sfc); + const loop = cfgs.find((c) => c.blocks.some((b) => b.text.includes('sum = sum + x'))); + expect(loop).toBeDefined(); + if (!loop) return; + + // The non-terminating loop has a back-edge but EXIT must still be reachable + // from EVERY block (the structural escape edge feeds the post-dom pass). + expect(edgeKinds(loop).has('loop-back')).toBe(true); + expect(isExitReachableFromAllBlocks(loop)).toBe(true); + + // Control dependence is computable and non-empty — the worker's CDG pass + // would emit > 0 edges for this function (matches the pipeline assertion). + const cd = computeControlDependence(loop); + expect(cd.edges.length).toBeGreaterThan(0); + for (const e of cd.edges) { + expect(['T', 'F']).toContain(e.label); + } + }); +}); From 5e96a99b0deb60bb3ea78f0006615a22be63f887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 15 Jun 2026 14:29:21 +0100 Subject: [PATCH 2/3] feat(cfg): model value-position branches as control dependence (#2205, #2207) (#2211) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(cfg): model Java value-position switch as control flow (#2207) A value-position `switch` expression with ≥2 arms is now modeled as a CFG dispatch in the two highest-value carriers, instead of collapsing the owning statement to a single inline block: - `var x = switch (k) { … }` — the arms become real blocks reached by `switch-case` edges and rejoin at a binding continuation that carries the declared name's def (uses stay on the arm blocks). - `return switch (k) { … }` — each arm returns the function result, threading every active finalizer. This makes the arms control-dependent on the dispatch (the point of #2207 — they previously produced zero CDG), mirroring the Kotlin / Rust value-position binding pattern. `breaksBlock` routes a value-switch declaration out of `visitSeq` coalescing; `visitReturn` and `visitStmt` gain the carrier handling; `java-harvest` gains `bindingDefFacts`. An assignment RHS (`x = switch …`), a call argument, and a multi- declarator decl remain inline (documented gap). Java has no value- position `if` (the ternary is excluded, like Kotlin's elvis). Verified: 51 java-visitor tests (4 new), full CFG unit+integration suites (661) green, CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): model C# value-position switch expression as control flow (#2207) A C# `switch_expression` (`k switch { p => v, … }`) with ≥2 arms is now modeled as a CFG `switch-case` dispatch — a discriminant block, each arm value a block reached by a dispatch edge, all arms rejoining at one exit — in the three value-position carriers, instead of collapsing the owning construct to a single inline block: - `var x = k switch { … }` — arms rejoin at a binding continuation that carries the declared name's def (discriminant + arm uses on the arms). - `return k switch { … }` — each arm returns the function result, threading every active finalizer. - `=> k switch { … }` expression-bodied member — each arm returns. The arms are now control-dependent on the discriminant (the point of #2207 — they previously produced zero CDG). Arm patterns / `when` guards are harvested as conditional uses on the dispatch; an unguarded `_`/`var` arm is the exhaustive catch-all (a non-exhaustive switch keeps EXIT reachable via a no-match edge). `switch_expression` is distinct from `switch_statement`, so this adds a dedicated `visitSwitchExpr`. An assignment RHS (`x = k switch …`), a call argument, and a multi- declarator decl remain inline (documented gap). `csharp-harvest` gains `bindingDefFacts`. Verified: 47 csharp-visitor tests (4 new), full CFG unit+integration suites (664) green, CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): model PHP value-position match expression as control flow (#2207) A PHP `match($v) { c => v, default => v }` with ≥2 arms is now modeled as a CFG `switch-case` dispatch — a discriminant block, each arm value a block reached by a dispatch edge, all arms rejoining at one exit (no fallthrough) — in the two value-position carriers, instead of collapsing the owning statement to one inline block: - `$x = match($v) { … }` — the dominant PHP idiom (no typed local decl): arms rejoin at a binding continuation carrying the assignment target's def (condition + arm uses on the arms). - `return match($v) { … }` — each arm returns the function result, threading every active finally. The arms are now control-dependent on the discriminant (the point of #2207). Arm `match_condition_list`s are harvested as conditional uses on the dispatch; a `default` arm is the catch-all (a defaultless `match` throws UnhandledMatchError, kept EXIT-reachable via a no-match edge). `php-harvest` gains `assignmentDefFacts`. A `match` in a call argument / nested subexpression stays inline; the ternary `?:` is excluded by design (a micro-branch, like elvis). Verified: 37 php-visitor tests (3 new), CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): model Dart value-position switch expression as control flow (#2207) A Dart 3 value-position `switch (v) { p => e, _ => e }` with ≥2 arms is now modeled as a CFG `switch-case` dispatch — a discriminant block, each arm value a block reached by a dispatch edge, all arms rejoining at one exit (no fallthrough) — in the two value-position carriers, instead of collapsing the owning statement to one inline block: - `var x = switch (v) { … }` — single-binding decl; arms rejoin at a binding continuation carrying the declared name's def. - `return switch (v) { … }` — each arm returns the function result, threading every active finalizer. The arms are now control-dependent on the discriminant (the point of #2207). A Dart call value parses as `identifier` + `selector` (multiple children, not one node), so the arm-value facts come from a dedicated `switchExprArmValueFacts`; arm patterns harvest conditionally onto the dispatch; a `_` arm is the catch-all (a non-exhaustive switch keeps EXIT reachable via a no-match edge). `dart-harvest` gains `bindingDefFacts` + the arm value/pattern fact helpers. A `switch_expression` in a call argument / multi-binding decl stays inline (its conditional arm sub-evaluation remains the #2206 harvest may-def path, re-pointed in the regression test). `?:`/`??`/`?.` excluded by design. Verified: 39 dart-visitor tests (4 new), CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): model Swift value-position if/switch as control flow (#2207) A Swift 5.9 value-position `if`/`switch` is now modeled as control flow in the two value-position carriers, instead of collapsing the owning statement to one inline block: - `let x = if … else … / switch v { … }` — arms rejoin at a binding continuation carrying the declared name's def (condition + arm uses on the branch blocks). - `return if … / switch …` — each arm returns the function result, threading every active finalizer. The arms are now control-dependent on the branch (the point of #2207). tree-sitter-swift reuses `if_statement` / `switch_statement` for the value form (no separate `if_expression`/`switch_expression`), so the existing `visitIf`/`visitSwitch` are reused — this mirrors the Kotlin carrier exactly. `swift-harvest` gains `bindingDefFacts`. A value-position `if` requires an `else`; a value `switch` needs ≥2 entries. A value branch in a call argument / interpolation stays inline; `?:`/`??` are excluded by design. Verified: 31 swift-visitor tests (4 new), CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * feat(cfg): model Kotlin assignment-RHS and value-position try as control flow (#2205) Completes the value-position branch carriers #2205 left deferred after the initial val/var-binding + return + expr-body work: - `x = when (k) { … }` / `x = if (c) a else b` / `x = try { … }` — a plain `=` assignment whose RHS is a modelable branch now models the arms as control flow and binds the LHS target at the rejoin (a compound `+=` and a plain-call RHS stay inline). - `val x = try { … } catch { … }` — a value-position `try` is now a modelable value branch (reusing visitTry), so the binding/assignment carriers route it through control flow too. The arms are now control-dependent on the branch (the point of #2205). `isModelableValueBranch` gains `try_expression`; `visitBranchExpr` routes it to `visitTry`; `isControlFlow`/`visitStmt` gain the `assignment` carrier; `kotlin-harvest` gains `assignmentDefFacts`. A branch nested in a call argument (`f(when …)`) stays inline (the direct value is the call); `?:`/`?.` micro-branches excluded by design. The `return try { … }` carrier is intentionally left out (finalizer-threading in return position is risky and was not requested). Verified: 43 kotlin-visitor tests (5 new), full CFG unit+integration suites (676) green, CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cfg): Java colon-form value switch — yield ends the arm, no fallthrough (#2211) Tri-review (adversarial + correctness lanes) found that a value-position colon-form switch expression — `int x = switch(k){ case 1: yield a(); case 2: yield b(); }` (valid Java 14+) — reused the statement `visitSwitch` fallthrough logic, wiring a spurious `fallthrough` edge between the yield-terminated colon groups. A switch EXPRESSION never falls through between arms; the false edge dropped an arm's control-dependence edge and added a false reaching-defs propagation edge (verified by a real-parser probe). Arrow-form value switches were already correct. Root cause: `visitYield` modeled `yield e;` as a block that CONTINUES to the next statement. Semantically `yield` produces the switch-expression's value and EXITS the switch. Fix: `visitYield` now terminates the arm, jumping to the enclosing switch's exit and threading any finalizer it crosses — exactly like a `break` out of the switch, but carrying the yielded value's facts. Adds `ControlFlowContext.resolveYield()` (nearest SWITCH frame, never an intervening loop). `yield` is Java-only here (C# `yield return` is iterator semantics, untouched). Tests: a colon-form value switch asserting NO `fallthrough` edge and that BOTH arms are control-dependent on the dispatch (specific controller→ dependent pairs), plus a `return switch(…)` inside `try/finally` asserting `finally-return` threading per arm. Verified: full CFG unit+integration suites green, CDG snapshot byte- identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cfg): Dart value switch — guarded `_` is not a catch-all; guard is a dispatch test (#2211) Tri-review (adversarial + correctness lanes) found two issues in the Dart `visitSwitchExpr` value-position modeling: 1. Catch-all detection was `pattern.text === '_'`, ignoring guards. A guarded `_ when c => …` is NOT exhaustive (Dart throws at runtime if no arm + guard matches), so falsely treating it as a catch-all suppressed the conservative no-match edge — asserting an exhaustive switch that isn't. The sibling C# visitor already gated catch-all on `!guard`. 2. A `when` guard parses as a bare sibling between the pattern and the value (no wrapper node), so it fell into the arm-VALUE children and was harvested as an unconditional arm-value use instead of a conditional dispatch test. Fix: new `armParts()` splits a `switch_expression_case` at the `=>` token into pattern / guard(s) / value(s). The pattern AND guard are harvested conditionally onto the dispatch block (they evaluate before the body, only when earlier arms missed); the arm-value facts come from the post-`=>` children only; the catch-all is gated on an unguarded `_`. Removes the now- unused dart-harvest `switchExprArm{Value,Pattern}Facts` (the visitor harvests per-child via the existing `facts`/`factsConditional`). Tests: a guarded value switch asserting the no-match edge is present (3 switch-case successors from the dispatch) and EXIT stays reachable, plus a test that the guard's use is recorded on the dispatch block, not an arm. Verified: full CFG suites green, CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * fix(cfg): Kotlin return try {…} models the value-position try (#2205, #2211) Tri-review (maintainability + testing lanes) caught a doc-vs-code mismatch: the visitor header documents a `return ` carrier that includes `try`, but `visitReturn` only matched `when_expression`/`if_expression`, so `return try { … } catch { … }` fell through to the single inline-block path — its arms were not modeled. Fix: `visitReturn` now also matches `try_expression`, making the `return` carrier uniform with the binding / assignment / expression-body carriers (all route a value-position `try` through `isModelableValueBranch` → `visitTry`, threading the active finalizers per arm). No new machinery — just completes the carrier set the docstring already claimed. Tests: `return try {…} catch {…}` (throw + return edges, CDG-bearing, EXIT reachable) and `x = try {…} catch {…}` assignment-RHS (the assignment carrier's try path, previously untested). Verified: full CFG suites green, CDG snapshot byte-identical, bench --check PASS. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): cover the no-match edge of non-exhaustive C#/PHP value switches (#2211) The testing lane noted the `hasCatchAll`/`hasDefault === false` branch — where a value-position `switch`/`match` with no catch-all/default arm adds a conservative no-match edge so EXIT stays reachable — was untested (every existing fixture used a `_`/`default` arm). Adds a C# `x switch { 1 => …, 2 => … }` and a PHP `match($x){ 1 => …, 2 => … }` (no default) test, each asserting the dispatch fans to (arms + 1) `switch-case` successors and `isExitReachableFromAllBlocks` holds. Verified: full CFG suites green. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cfg): clarify Kotlin value-position try gate covers the finally-only path (#2211) Tri-review (maintainability lane) noted the inline comment at the `try_expression` branch of `isModelableValueBranch` said only "a catch's value", but the gate fires on `catch_block || finally_block`. Reword to acknowledge that a value-position `try` with a `catch` OR a `finally` is a modelable branch. Comment-only; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) * docs(cfg): document the deliberate C# declaratorInit duplication (#2211) Tri-review (maintainability lane) flagged the byte-identical `declaratorInit` helper in `csharp.ts` (visitor) and `csharp-harvest.ts` (harvester). The two are standalone classes with no shared base (repo convention) and the only module both import is the generic `utils/ast-helpers` (types only) — not a home for a C#-grammar-specific helper. Resolve the lowest-risk way: a cross-reference comment at each definition noting the deliberate duplication and the keep-in-sync requirement. No new shared module; no behavior change. Co-Authored-By: Claude Opus 4.8 (1M context) * refactor(cfg): unwrap the paren in Dart visitSwitch for dispatch consistency (#2211) Tri-review (maintainability lane) noted `visitSwitch` (statement form) used the raw parenthesized `condition` (dispatch text `switch (x)`) while the new `visitSwitchExpr` unwraps it (`switch x`). Probed the vendored tree-sitter-dart: the `switch_statement` condition IS a `parenthesized_expression`, so apply `unwrapParen` in `visitSwitch` too. The harvest walks into the paren either way, so the discriminant's def/use facts are unchanged — only the dispatch block's text string normalizes. Verified byte-identical (cdg-snapshot + bench --check unchanged; the Dart unit tests assert topology, not block text). Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): Swift single-entry value switch stays inline (#2211) Tri-review (testing lane) noted the existing Swift "stays inline" test used a plain call (`let x = g()`), which never exercises the single-entry switch gate. Add a real one-entry value switch (`let x = switch v { default: g() }`) asserting it coalesces (no switch-case edge), pinning the `>= 2` switch_entry threshold in `isModelableValueBranch`. Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): cover the Kotlin expression-body try carrier (#2205, #2211) Tri-review (testing lane) noted the `fun f() = try { … } catch { … }` expression-body carrier (visitExprBody -> isModelableValueBranch accepting try_expression) existed but was untested. Add a regression asserting the expr-body try is modeled (throw + return edges, CDG-bearing, EXIT reachable). Co-Authored-By: Claude Opus 4.8 (1M context) * test(cfg): pin the value-branch carriers never throw on a truncated AST (R4) (#2211) Tri-review (adversarial lane) noted the new value-position branch carriers must return undefined / never throw on a malformed AST, or a single bad function would drop the whole file's CFG group (the R4 invariant). Add a per-language regression feeding a TRUNCATED value-branch carrier (an unterminated `var x = switch/match/if/when (…)`) through the existing `collectFunctions` + `buildFunctionCfg(...).not.toThrow()` graceful-undefined harness, for all six languages whose value-branch path is new (Java/C#/PHP/Dart/Swift/Kotlin). Co-Authored-By: Claude Opus 4.8 (1M context) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- .../ingestion/cfg/control-flow-context.ts | 12 ++ .../ingestion/cfg/visitors/csharp-harvest.ts | 29 ++- .../src/core/ingestion/cfg/visitors/csharp.ts | 188 +++++++++++++++++- .../ingestion/cfg/visitors/dart-harvest.ts | 23 +++ .../src/core/ingestion/cfg/visitors/dart.ts | 180 ++++++++++++++++- .../ingestion/cfg/visitors/java-harvest.ts | 18 ++ .../src/core/ingestion/cfg/visitors/java.ts | 136 +++++++++++-- .../ingestion/cfg/visitors/kotlin-harvest.ts | 19 ++ .../src/core/ingestion/cfg/visitors/kotlin.ts | 86 ++++++-- .../ingestion/cfg/visitors/php-harvest.ts | 18 ++ .../src/core/ingestion/cfg/visitors/php.ts | 156 ++++++++++++++- .../ingestion/cfg/visitors/swift-harvest.ts | 18 ++ .../src/core/ingestion/cfg/visitors/swift.ts | 85 ++++++++ gitnexus/test/unit/cfg/csharp-visitor.test.ts | 73 ++++++- gitnexus/test/unit/cfg/dart-visitor.test.ts | 88 +++++++- gitnexus/test/unit/cfg/java-visitor.test.ts | 103 +++++++++- gitnexus/test/unit/cfg/kotlin-visitor.test.ts | 79 ++++++++ gitnexus/test/unit/cfg/php-visitor.test.ts | 55 ++++- gitnexus/test/unit/cfg/swift-visitor.test.ts | 72 +++++++ 19 files changed, 1370 insertions(+), 68 deletions(-) diff --git a/gitnexus/src/core/ingestion/cfg/control-flow-context.ts b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts index 6b9bf6c1c..0bfdef425 100644 --- a/gitnexus/src/core/ingestion/cfg/control-flow-context.ts +++ b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts @@ -126,6 +126,18 @@ export class ControlFlowContext { ); } + /** + * Resolve a Java `yield e` (switch-EXPRESSION arm exit): the nearest enclosing + * SWITCH frame's exit, threading the finalizers stacked above it. Unlike a + * `break`, a `yield` ALWAYS targets the switch — never an intervening loop — so + * it cannot match a loop frame (a `yield` inside a loop inside a switch arm + * still exits the whole switch). Returns `undefined` when there is no enclosing + * switch (malformed input); the caller falls back to its conservative routing. + */ + resolveYield(): JumpResolution | undefined { + return this.resolve((f) => f.kind === 'switch'); + } + /** Every active finalizer, innermost first — what a `return` must cross. */ finalizersForReturn(): readonly FinalizerFrame[] { const fins: FinalizerFrame[] = []; diff --git a/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts index 5e0296bf3..bcc615ab2 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts @@ -220,6 +220,27 @@ export class CsharpHarvester extends ScopeTreeHarvester { return acc.finish(); } + /** + * Def-ONLY facts for a value-position binding carrier (`var x = k switch {…}`, + * #2207): just the declared name(s)' def, attached to the continuation block the + * switch arms rejoin. The discriminant + arm-value USES are already harvested + * onto the branch's own blocks ({@link facts} on each arm), so this must NOT + * re-walk the initializer — only each `variable_declarator`'s name is a def here. + */ + bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + const decl = stmt.namedChildren.find((c) => c.type === 'variable_declaration'); + if (decl) { + for (let i = 0; i < decl.namedChildCount; i++) { + const d = decl.namedChild(i); + if (d?.type !== 'variable_declarator') continue; + const name = d.childForFieldName('name'); + if (name) this.def(name, acc); + } + } + return acc.defCount() ? acc.finish() : undefined; + } + /** Facts for a `foreach (decl in right)` head: decl binds, right is used. */ forEachHeadFacts(stmt: SyntaxNode): StatementFacts { const acc = new FactAccumulator(stmt.startPosition.row + 1); @@ -548,7 +569,13 @@ export class CsharpHarvester extends ScopeTreeHarvester { return { path, rootIdx }; } - /** The initializer value of a `variable_declarator` — the named child after `name`. */ + /** + * The initializer value of a `variable_declarator` — the named child after + * `name`. NOTE: deliberately duplicated in `csharp.ts` (the visitor is a + * standalone class with no shared base — repo convention). The two copies must + * stay in sync; there is no C#-specific shared module to host it, and the only + * module both files share is the generic `utils/ast-helpers` (types only). + */ private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined { const name = declarator.childForFieldName('name'); for (let i = 0; i < declarator.namedChildCount; i++) { diff --git a/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts b/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts index 49b6a6dbc..c3da50fbc 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts @@ -66,6 +66,12 @@ * unresolved label. * - Async/await suspension points are modeled as straight-line (the awaited * continuation is not a separate flow), consistent with the TS visitor. + * - A value-position `switch_expression` (`k switch {…}`) with ≥2 arms IS modeled + * as a `switch-case` dispatch in three carriers (#2207): a single-declarator + * `var x = k switch {…}` (arms rejoin at a binding continuation), `return k + * switch {…}`, and an `=> k switch {…}` expression body (each arm returns). + * A value switch in any OTHER position — an assignment RHS (`x = k switch …`), + * a call argument, or a multi-declarator decl — stays INLINE (one block). * - Def/use harvest scope: see `csharp-harvest.ts` — member/element writes are * not scalar defs; nested-function bodies are opaque in both directions. * @@ -186,7 +192,7 @@ class CsharpCfgWalk { dangling = [...scope.exits]; break; // the rest of the sequence is consumed by the dispose scope } - if (CONTROL_FLOW_TYPES.has(stmt.type)) { + if (this.breaksBlock(stmt)) { openSimple = undefined; // close any open straight-line block const res = this.visitStmt(stmt); if (res === null) continue; // transparent (empty nested block) @@ -222,9 +228,28 @@ class CsharpCfgWalk { }); } + /** + * Whether a statement breaks the current straight-line block. Adds the + * value-position switch carrier to the base {@link CONTROL_FLOW_TYPES} set: a + * `local_declaration_statement` whose single initializer is a modelable + * `switch_expression` (`var x = k switch {…}`, #2207) breaks so `visitStmt` + * models the arms as control flow instead of collapsing the decl to one block. + */ + private breaksBlock(stmt: SyntaxNode): boolean { + if (this.isValueSwitchDecl(stmt)) return true; + return CONTROL_FLOW_TYPES.has(stmt.type); + } + /** Dispatch one statement to its handler. Non-null except for empty blocks. */ visitStmt(stmt: SyntaxNode): SeqResult { switch (stmt.type) { + case 'local_declaration_statement': { + // `var x = k switch { … }` (#2207): the initializer is a value-position + // branch — model it as control flow and bind the result on the rejoin. + const branch = this.declValueSwitch(stmt); + if (branch) return this.visitBindBranch(stmt, branch); + return this.visitSimple(stmt); + } case 'if_statement': return this.visitIf(stmt); case 'while_statement': @@ -276,6 +301,18 @@ class CsharpCfgWalk { } private visitReturn(stmt: SyntaxNode): TraversalResult { + // `return k switch { … };` (#2207): the returned value is a value-position + // branch — model it as control flow, with each arm returning (its value IS + // the function result), threading every active finalizer per arm. + const branch = stmt.namedChildren.find((c) => c.type !== 'comment'); + if (branch && this.isModelableValueBranch(branch)) { + const res = this.visitBranchExpr(branch); + const finalizers = this.cfc.finalizersForReturn(); + for (const ex of res.exits) { + wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return'); + } + return { entry: res.entry, exits: [] }; + } const idx = this.builder.newBlock( startLineOf(stmt), endLineOf(stmt), @@ -656,6 +693,147 @@ class CsharpCfgWalk { return node.type.endsWith('_statement') || node.type === 'block'; } + // ── value-position switch expression (#2207) ──────────────────────────────── + + /** + * The `switch_expression` initializer of a single-declarator + * `local_declaration_statement` (`var x = k switch {…}`) when it is a modelable + * value branch, else undefined. A `using` decl and a multi-declarator decl are + * excluded (the `using` dispose path / multi-declarator stay inline). + */ + private declValueSwitch(stmt: SyntaxNode): SyntaxNode | undefined { + if (stmt.type !== 'local_declaration_statement') return undefined; + if (this.isUsingLocalDecl(stmt)) return undefined; + const decl = stmt.namedChildren.find((c) => c.type === 'variable_declaration'); + if (!decl) return undefined; + const declarators = decl.namedChildren.filter((c) => c.type === 'variable_declarator'); + if (declarators.length !== 1) return undefined; + const init = this.declaratorInit(declarators[0]); + return init && this.isModelableValueBranch(init) ? init : undefined; + } + + private isValueSwitchDecl(stmt: SyntaxNode): boolean { + return this.declValueSwitch(stmt) !== undefined; + } + + /** + * The initializer of a `variable_declarator` — its named child after `name`. + * NOTE: deliberately duplicated in `csharp-harvest.ts` (the harvester is a + * standalone class with no shared base — repo convention). The two copies must + * stay in sync; there is no C#-specific shared module to host it, and the only + * module both files share is the generic `utils/ast-helpers` (types only). + */ + private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined { + const name = declarator.childForFieldName('name'); + for (let i = 0; i < declarator.namedChildCount; i++) { + const c = declarator.namedChild(i); + if (c && c.id !== name?.id) return c; + } + return undefined; + } + + /** + * Whether `node` is a value-position branch worth modeling as control flow + * (#2207): a `switch_expression` (`k switch {…}`) with ≥2 arms — a real + * dispatch. C# value-position `if` does not exist (the ternary `?:` is excluded, + * like elvis in Kotlin). + */ + private isModelableValueBranch(node: SyntaxNode): boolean { + if (node.type !== 'switch_expression') return false; + return node.namedChildren.filter((c) => c.type === 'switch_expression_arm').length >= 2; + } + + /** + * Model a value-position `switch_expression` (`k switch { p => v, … }`) as a CFG + * dispatch: a discriminant block, each arm's value expression a block reached by + * a `switch-case` edge, all arms rejoining at a single exit. The arm patterns / + * `when` guards are harvested as conditional uses on the dispatch (a later arm + * test runs only when earlier arms didn't match), mirroring {@link visitSwitch}. + */ + private visitSwitchExpr(node: SyntaxNode): TraversalResult { + const arms = node.namedChildren.filter((c) => c.type === 'switch_expression_arm'); + const discriminant = node.namedChildren.find((c) => c.type !== 'switch_expression_arm') ?? node; + const dispatch = this.builder.newBlock( + startLineOf(node), + endLineOf(discriminant), + discriminant.text, + 'normal', + this.harvest.facts(discriminant), + ); + const switchExit = this.builder.newBlock(endLineOf(node), endLineOf(node), ''); + + let hasCatchAll = false; + for (const arm of arms) { + const pattern = arm.namedChild(0); + const guard = arm.namedChildren.find((c) => c.type === 'when_clause'); + if (pattern) this.builder.attachFacts(dispatch, this.harvest.factsConditional(pattern)); + if (guard) { + const inner = guard.namedChild(0); + if (inner) this.builder.attachFacts(dispatch, this.harvest.factsConditional(inner)); + } + // An unguarded `_`/`var` arm matches everything — the exhaustive default. + if (!guard && pattern && (pattern.type === 'discard' || pattern.type === 'var_pattern')) { + hasCatchAll = true; + } + const value = this.armValue(arm); + const armBlock = this.builder.newBlock( + startLineOf(value ?? arm), + endLineOf(value ?? arm), + (value ?? arm).text, + 'normal', + value ? this.harvest.facts(value) : undefined, + ); + this.builder.edge(dispatch, armBlock, 'switch-case'); + this.builder.edge(armBlock, switchExit, 'seq'); + } + // A non-exhaustive switch throws at runtime; conservatively keep EXIT directly + // reachable from the dispatch when no catch-all arm covers the no-match path. + if (!hasCatchAll) this.builder.edge(dispatch, switchExit, 'switch-case'); + + return { entry: dispatch, exits: [switchExit] }; + } + + /** The value expression of a `switch_expression_arm` (the child after `=>`). */ + private armValue(arm: SyntaxNode): SyntaxNode | undefined { + // pattern [when_clause] => value — the value is the LAST named child. + return arm.namedChild(arm.namedChildCount - 1) ?? undefined; + } + + /** Model a value-position branch as control flow (only `switch_expression`). */ + private visitBranchExpr(node: SyntaxNode): TraversalResult { + return this.visitSwitchExpr(node); + } + + /** + * An expression-bodied member's value (`=> k switch {…}`, #2207): if it is a + * modelable value branch, model its arms as control flow (each arm returns the + * function result); otherwise return null so the caller falls back to a single + * inline block. + */ + tryVisitValueBranchBody(expr: SyntaxNode): TraversalResult | null { + return this.isModelableValueBranch(expr) ? this.visitBranchExpr(expr) : null; + } + + /** + * `var x = k switch { … }` (#2207): visit the switch as control flow, then + * rejoin its arms at a facts-only continuation carrying ONLY the bound name's + * def (the discriminant + arm-value uses are already on the switch's blocks). + * The arms are now control-dependent on the dispatch, and `x` is defined at the + * join — mirrors the Java / Kotlin / Rust value-position binding. + */ + private visitBindBranch(stmt: SyntaxNode, branch: SyntaxNode): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.bindingDefFacts(stmt), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + private visitTry(stmt: SyntaxNode): SeqResult { const bodyNode = stmt.childForFieldName('body'); const catchClauses: SyntaxNode[] = []; @@ -924,6 +1102,14 @@ function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | u // Expression-bodied member / single-expression lambda: one block whose // value is returned. For an arrow clause the value is its inner expression. const expr = body.type === 'arrow_expression_clause' ? (body.namedChild(0) ?? body) : body; + // `=> k switch { … }` (#2207): model the arms as control flow, each arm + // returning the function result, instead of one inline block. + const branchRes = new CsharpCfgWalk(builder, harvest).tryVisitValueBranchBody(expr); + if (branchRes) { + builder.edge(builder.entryIndex, branchRes.entry, 'seq'); + builder.connect(branchRes.exits, builder.exitIndex, 'return'); + return builder.finish(harvest.bindingTable()); + } const blk = builder.newBlock( startLineOf(expr), endLineOf(expr), diff --git a/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts index ff26c6971..9234c9dd0 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts @@ -280,6 +280,29 @@ export class DartHarvester { return acc.finish(); } + /** + * Def-ONLY facts for a value-position binding carrier (`var x = switch (…) {…}`, + * #2207): just the declared name(s)' def, attached to the continuation block the + * switch arms rejoin. The subject + arm-value USES are already harvested onto + * the branch's own blocks, so this must NOT re-walk the value — only each + * `initialized_variable_definition`'s `name` (and trailing binders) is a def. + */ + bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + for (const def of stmt.namedChildren) { + if (def.type !== 'initialized_variable_definition') continue; + const name = def.childForFieldName('name'); + if (name) this.def(name, acc); + for (let i = 0; i < def.namedChildCount; i++) { + const c = def.namedChild(i); + if (c?.type !== 'initialized_identifier') continue; + const id = c.namedChildren.find((g) => g.type === 'identifier'); + if (id) this.def(id, acc); + } + } + return acc.defCount() ? acc.finish() : undefined; + } + /** * Facts for a `for` head. For-in: the loop var name is a def, the collection a * use. C-style: the init/condition/update sub-expressions are walked for diff --git a/gitnexus/src/core/ingestion/cfg/visitors/dart.ts b/gitnexus/src/core/ingestion/cfg/visitors/dart.ts index 64253055c..e4ab651df 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/dart.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/dart.ts @@ -88,10 +88,13 @@ * - a closure (`function_expression`) is collected as its OWN function by * `isFunction`, so its body gets a standalone CFG; in the ENCLOSING function it * is an opaque straight-line value (its body is not followed inline). - * - `switch_expression` / `if`-as-expression / `?:` / `??` / `?.` used as a VALUE - * are left INLINE inside their owning statement's block — their conditional - * sub-evaluation is a HARVEST may-def concern (see dart-harvest.ts), not a CFG - * split (consistent with the TS `&&`/`??` treatment). + * - a value-position `switch_expression` (Dart 3) with ≥2 arms IS modeled as a + * `switch-case` dispatch in two carriers (#2207): a single-binding `var x = + * switch (v) {…}` (arms rejoin at a binding continuation) and `return switch + * (v) {…}` (each arm returns). A `switch_expression` in any OTHER position — a + * call argument, a multi-binding decl — stays INLINE (its conditional arm + * sub-evaluation is a HARVEST may-def concern, see dart-harvest.ts). `?:` / + * `??` / `?.` micro-branches are excluded by design (like the TS treatment). * * Known limitations: * - block-scope shadowing in the harvest is flattened to one function table (see @@ -249,6 +252,12 @@ class DartCfgWalk { private isControlFlow(stmt: SyntaxNode): boolean { if (this.isLabelError(stmt)) return true; // a stray label sibling — queue it if (isThrowStatement(stmt) || isRethrowStatement(stmt)) return true; + // `var x = switch (v) { … }` (#2207): a value-position switch breaks so + // `visitStmt` models the arms as control flow instead of coalescing. + if (stmt.type === 'local_variable_declaration') { + const v = this.directValue(stmt); + return v !== undefined && this.isModelableValueBranch(v); + } return CONTROL_FLOW_TYPES.has(stmt.type); } @@ -273,6 +282,13 @@ class DartCfgWalk { if (isThrowStatement(stmt)) return this.visitThrow(stmt); if (isRethrowStatement(stmt)) return this.visitRethrow(stmt); switch (stmt.type) { + case 'local_variable_declaration': { + // `var x = switch (v) { … }` (#2207): the value is a value-position + // branch — model it as control flow and bind the result on the rejoin. + const value = this.directValue(stmt); + if (value && this.isModelableValueBranch(value)) return this.visitBindBranch(stmt, value); + return this.visitSimple(stmt); + } case 'if_statement': return this.visitIf(stmt); case 'for_statement': @@ -322,6 +338,18 @@ class DartCfgWalk { /** `return [expr];` — threads through every active finalizer before EXIT. */ private visitReturn(stmt: SyntaxNode): TraversalResult { + // `return switch (v) { … };` (#2207): the returned value is a value-position + // branch — model it as control flow, with each arm returning (its value IS + // the function result), threading every active finalizer per arm. + const branch = stmt.namedChildren.find((c) => !isComment(c)); + if (branch && this.isModelableValueBranch(branch)) { + const res = this.visitBranchExpr(branch); + const finalizers = this.cfc.finalizersForReturn(); + for (const ex of res.exits) { + wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return'); + } + return { entry: res.entry, exits: [] }; + } const idx = this.builder.newBlock( startLineOf(stmt), endLineOf(stmt), @@ -579,7 +607,12 @@ class DartCfgWalk { */ private visitSwitch(stmt: SyntaxNode): TraversalResult { const labels = this.takeLabels(); - const value = stmt.childForFieldName('condition'); + // The `condition` field is a `parenthesized_expression` (verified) — unwrap it + // so the dispatch text/discriminant matches the value-position `visitSwitchExpr` + // form (`switch x`, not `switch (x)`). The harvest walks into the paren either + // way, so the def/use facts are unchanged — only the block text normalizes. + const condRaw = stmt.childForFieldName('condition'); + const value = condRaw ? this.unwrapParen(condRaw) : undefined; const dispatch = this.builder.newBlock( startLineOf(stmt), value ? endLineOf(value) : startLineOf(stmt), @@ -705,6 +738,143 @@ class DartCfgWalk { return id?.text || undefined; } + // ── value-position switch expression (#2207) ──────────────────────────────── + + /** + * The direct value of a `local_variable_declaration` with a SINGLE + * `initialized_variable_definition` (`var x = `): its `value` field. + * Returns undefined for a multi-binding decl (`var a = …, b = …`) — modeling + * those arm-by-arm is out of scope, so they coalesce inline. + */ + private directValue(stmt: SyntaxNode): SyntaxNode | undefined { + const defs = stmt.namedChildren.filter((c) => c.type === 'initialized_variable_definition'); + if (defs.length !== 1) return undefined; + return defs[0].childForFieldName('value') ?? undefined; + } + + /** + * Whether `node` is a value-position branch worth modeling as control flow + * (#2207): a `switch_expression` (Dart 3) with ≥2 arms — a real dispatch. Dart's + * value-position `if` does not exist; the ternary `?:` is excluded by design. + */ + private isModelableValueBranch(node: SyntaxNode): boolean { + if (node.type !== 'switch_expression') return false; + return node.namedChildren.filter((c) => c.type === 'switch_expression_case').length >= 2; + } + + /** Model a value-position branch as control flow (only `switch_expression`). */ + private visitBranchExpr(node: SyntaxNode): TraversalResult { + return this.visitSwitchExpr(node); + } + + /** + * Model a value-position `switch (v) { p [when g] => e, _ => e }` (Dart 3) as a + * CFG dispatch: a discriminant block, each arm's value a block reached by a + * `switch-case` edge, all arms rejoining at one exit (no fallthrough). The arm + * PATTERN and any `when` GUARD are harvested as conditional uses on the dispatch + * (they evaluate before the body, only when earlier arms missed); a Dart call + * value parses as `identifier` + `selector` (multiple children), so the arm-value + * facts come from each post-`=>` child. Only an UNGUARDED `_` arm is the + * exhaustive catch-all — a guarded `_ when …` is NOT (the no-match path still + * needs the conservative edge), mirroring the C# `visitSwitchExpr`. + */ + private visitSwitchExpr(node: SyntaxNode): TraversalResult { + const condRaw = node.childForFieldName('condition'); + const cond = condRaw ? this.unwrapParen(condRaw) : node; + const dispatch = this.builder.newBlock( + startLineOf(node), + endLineOf(cond), + `switch ${cond.text}`, + 'normal', + this.harvest.facts(cond), + ); + const switchExit = this.builder.newBlock(endLineOf(node), endLineOf(node), ''); + + const arms = node.namedChildren.filter((c) => c.type === 'switch_expression_case'); + let hasCatchAll = false; + for (const arm of arms) { + const { pattern, guards, values } = this.armParts(arm); + // The pattern + `when` guard are conditional dispatch tests, NOT arm-value + // uses — harvest them onto the dispatch (mirrors casePatterns for switch_statement). + if (pattern) this.builder.attachFacts(dispatch, this.harvest.factsConditional(pattern)); + for (const g of guards) this.builder.attachFacts(dispatch, this.harvest.factsConditional(g)); + if (pattern && pattern.text === '_' && guards.length === 0) hasCatchAll = true; + const first = values[0] ?? arm; + const last = values[values.length - 1] ?? arm; + const armBlock = this.builder.newBlock( + startLineOf(first), + endLineOf(last), + values.map((c) => c.text).join('') || arm.text, + 'normal', + undefined, + ); + for (const v of values) this.builder.attachFacts(armBlock, this.harvest.facts(v)); + this.builder.edge(dispatch, armBlock, 'switch-case'); + this.builder.edge(armBlock, switchExit, 'seq'); + } + // A non-exhaustive Dart switch expression throws at runtime; conservatively + // keep EXIT reachable via a no-match edge when no `_` catch-all arm exists. + if (!hasCatchAll) this.builder.edge(dispatch, switchExit, 'switch-case'); + + return { entry: dispatch, exits: [switchExit] }; + } + + /** + * Split a `switch_expression_case` at the `=>` token: the PATTERN (first named + * child before `=>`), any `when` GUARD (named children between the pattern and + * `=>` — tree-sitter-dart parses the guard as a bare sibling, not a wrapper), + * and the VALUE expression (named children after `=>` — a Dart call is split + * across `identifier` + `selector`, hence an array). + */ + private armParts(arm: SyntaxNode): { + pattern: SyntaxNode | undefined; + guards: SyntaxNode[]; + values: SyntaxNode[]; + } { + const before: SyntaxNode[] = []; + const values: SyntaxNode[] = []; + let seenArrow = false; + for (let i = 0; i < arm.childCount; i++) { + const c = arm.child(i); + if (!c) continue; + if (!c.isNamed) { + if (c.text === '=>') seenArrow = true; + continue; + } + if (isComment(c)) continue; + (seenArrow ? values : before).push(c); + } + return { pattern: before[0], guards: before.slice(1), values }; + } + + /** Strip a `parenthesized_expression` wrapper (a switch/if condition). */ + private unwrapParen(node: SyntaxNode): SyntaxNode { + if (node.type === 'parenthesized_expression') { + const inner = node.namedChildren.find((c) => !isComment(c)); + if (inner) return inner; + } + return node; + } + + /** + * `var x = switch (v) { … }` (#2207): visit the switch as control flow, then + * rejoin its arms at a facts-only continuation carrying ONLY the declared name's + * def (the subject + arm-value uses are already on the switch's blocks). The + * arms are now control-dependent on the dispatch — mirrors Java / Kotlin / Rust. + */ + private visitBindBranch(stmt: SyntaxNode, branch: SyntaxNode): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.bindingDefFacts(stmt), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + // ── try / on / catch / finally ───────────────────────────────────────────── /** diff --git a/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts index 549d212f7..c1e1c7e48 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/java-harvest.ts @@ -196,6 +196,24 @@ export class JavaHarvester extends ScopeTreeHarvester { return acc.finish(); } + /** + * Def-ONLY facts for a value-position binding carrier (`var x = switch (…) {…}`, + * #2207): just the declared name(s)' def, attached to the continuation block the + * switch arms rejoin. The switch subject + arm-value USES are already harvested + * onto the branch's own blocks ({@link facts} on each arm), so this must NOT + * re-walk the value — only each `variable_declarator`'s `name` is a def here. + */ + bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + for (let i = 0; i < stmt.namedChildCount; i++) { + const d = stmt.namedChild(i); + if (d?.type !== 'variable_declarator') continue; + const name = d.childForFieldName('name'); + if (name) this.def(name, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + /** Facts for a `for (T name : value)` head: name binds, value is used. */ forEachHeadFacts(stmt: SyntaxNode): StatementFacts { const acc = new FactAccumulator(stmt.startPosition.row + 1); diff --git a/gitnexus/src/core/ingestion/cfg/visitors/java.ts b/gitnexus/src/core/ingestion/cfg/visitors/java.ts index f9ba90a7a..236185d64 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/java.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/java.ts @@ -74,11 +74,12 @@ * TS `visitTry` over-approximation. * * Known limitations: - * - `switch` as an EXPRESSION value (`int r = switch (x) { … };`) is left INLINE - * inside its owning statement's block — its arms are not modeled as separate - * CFG blocks (the value flows to the assignment). Only a `switch` used as a - * STATEMENT (a direct statement child) is modeled as a dispatch construct. - * This mirrors the C# `switch_expression`-in-return handling — documented gap. + * - A value-position `switch` with ≥2 arms is modeled as control flow in the two + * highest-value carriers (#2207): a single-declarator `var x = switch (…) {…}` + * (arms rejoin at a binding continuation) and `return switch (…) {…}` (each arm + * returns). A value-position `switch` in any OTHER position — an assignment RHS + * (`x = switch …`), a call argument, or a multi-declarator decl — is still left + * INLINE inside its owning block (the value flows to one coalesced block). * - `yield` (in a switch expression) continues to the next statement (it yields * one value to the enclosing switch and the arm ends); the switch-expression * state machine is not modeled, consistent with the inline-value-switch gap. @@ -197,12 +198,7 @@ class JavaCfgWalk { let openSimple: number | undefined; for (const stmt of stmts) { - // A `switch_expression` only breaks a block when it is a STATEMENT switch. - // Used as a value (inside a declaration / return) it coalesces normally. - const breaks = - CONTROL_FLOW_TYPES.has(stmt.type) && - (stmt.type !== 'switch_expression' || this.isStatementSwitch(stmt)); - if (breaks) { + if (this.breaksBlock(stmt)) { openSimple = undefined; // close any open straight-line block const res = this.visitStmt(stmt); if (res === null) continue; // transparent (empty nested block) @@ -238,9 +234,35 @@ class JavaCfgWalk { }); } + /** + * Whether a statement breaks the current straight-line block. A + * `switch_expression` breaks only when it is a STATEMENT switch (a value- + * position switch used directly inside a `block` coalesces). A + * `local_variable_declaration` whose value is a modelable value-position switch + * (`var x = switch (…) {…}`, #2207) also breaks — `visitStmt` then models the + * arms as control flow instead of collapsing the decl to one inline block. + */ + private breaksBlock(stmt: SyntaxNode): boolean { + if (stmt.type === 'local_variable_declaration') { + const v = this.directValue(stmt); + return v !== undefined && this.isModelableValueBranch(v); + } + if (!CONTROL_FLOW_TYPES.has(stmt.type)) return false; + if (stmt.type === 'switch_expression') return this.isStatementSwitch(stmt); + return true; + } + /** Dispatch one statement to its handler. Non-null except for empty blocks. */ visitStmt(stmt: SyntaxNode): SeqResult { switch (stmt.type) { + case 'local_variable_declaration': { + // `var x = switch (k) { … }` (#2207): the value is a value-position + // branch — model it as control flow and bind the result on the rejoin, + // instead of collapsing the whole decl to one block. + const value = this.directValue(stmt); + if (value && this.isModelableValueBranch(value)) return this.visitBindBranch(stmt, value); + return this.visitSimple(stmt); + } case 'if_statement': return this.visitIf(stmt); case 'while_statement': @@ -289,6 +311,18 @@ class JavaCfgWalk { } private visitReturn(stmt: SyntaxNode): TraversalResult { + // `return switch (k) { … };` (#2207): the returned value is a value-position + // branch — model it as control flow, with each arm returning (its value IS + // the function result), threading every active finalizer per arm. + const branch = stmt.namedChildren.find((c) => !isComment(c)); + if (branch && this.isModelableValueBranch(branch)) { + const res = this.visitBranchExpr(branch); + const finalizers = this.cfc.finalizersForReturn(); + for (const ex of res.exits) { + wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return'); + } + return { entry: res.entry, exits: [] }; + } const idx = this.builder.newBlock( startLineOf(stmt), endLineOf(stmt), @@ -321,10 +355,13 @@ class JavaCfgWalk { } /** - * `yield e;` (switch-expression arm value) — yields one value to the enclosing - * switch and the arm ends; modeled as a block that continues to whatever - * follows (the switch-expression state machine is not modeled, see the visitor - * limitations). It carries the yielded value's def/use facts. + * `yield e;` (switch-expression arm value) — produces the switch-expression's + * value and EXITS the enclosing switch (it does NOT fall through to the next + * colon group). Modeled as a terminator that jumps to the switch exit, threading + * any finalizer it crosses — exactly like a `break` out of the switch but + * carrying the yielded value's def/use facts. (Reusing the statement `visitSwitch` + * for a value-position colon switch would otherwise wire a spurious `fallthrough` + * edge between yield-terminated arms — #2211 review.) */ private visitYield(stmt: SyntaxNode): TraversalResult { const idx = this.builder.newBlock( @@ -334,7 +371,13 @@ class JavaCfgWalk { 'normal', this.harvest.facts(stmt), ); - return { entry: idx, exits: [idx] }; + const res = this.cfc.resolveYield(); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break'); + return { entry: idx, exits: [] }; } private visitBreak(stmt: SyntaxNode): TraversalResult { @@ -717,6 +760,67 @@ class JavaCfgWalk { return label.namedChildren.filter((c) => !isComment(c)).length === 0; } + // ── value-position branches (#2207) ───────────────────────────────────────── + + /** + * The direct value of a `local_variable_declaration` with a SINGLE declarator: + * its `variable_declarator`'s `value` field (`var x = `). Returns + * undefined for a multi-declarator decl (`int a = …, b = …;`) — modeling those + * arm-by-arm is out of scope, so they coalesce inline. The DIRECT value only: + * `var x = f(switch …)` yields the call, not the nested switch, so an + * argument-position switch stays inline. + */ + private directValue(stmt: SyntaxNode): SyntaxNode | undefined { + const declarators = stmt.namedChildren.filter((c) => c.type === 'variable_declarator'); + if (declarators.length !== 1) return undefined; + return declarators[0].childForFieldName('value') ?? undefined; + } + + /** + * Whether `node` is a value-position branch worth modeling as control flow + * (#2207): a `switch_expression` with ≥2 case groups (a real dispatch). Java has + * no value-position `if` (the ternary `?:` is deliberately excluded, like elvis + * in Kotlin), so `switch` is the only carrier. + */ + private isModelableValueBranch(node: SyntaxNode): boolean { + if (node.type !== 'switch_expression') return false; + const body = node.childForFieldName('body'); + if (!body) return false; + const groups = body.namedChildren.filter( + (c) => c.type === 'switch_block_statement_group' || c.type === 'switch_rule', + ); + return groups.length >= 2; + } + + /** + * Model a value-position `switch` as control flow regardless of position — + * {@link visitSeq}'s `isStatementSwitch` gate keeps value-position switches + * inline, so call {@link visitSwitch} directly here. + */ + private visitBranchExpr(node: SyntaxNode): TraversalResult { + return this.visitSwitch(node); + } + + /** + * `var x = switch (k) { … }` (#2207): visit the switch as control flow, then + * rejoin its arms at a facts-only continuation carrying ONLY the bound name's + * def (the subject + arm-value uses are already harvested onto the switch's + * blocks). The arms are now control-dependent on the dispatch, and `x` is + * defined at the join — mirrors the Kotlin / Rust value-position binding. + */ + private visitBindBranch(stmt: SyntaxNode, branch: SyntaxNode): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.bindingDefFacts(stmt), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + /** * try / catch / finally / try-with-resources. The `resources` of a * try-with-resources auto-close on BOTH normal and exception exit — exactly diff --git a/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts index f226112f7..2d27b3d89 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/kotlin-harvest.ts @@ -292,6 +292,25 @@ export class KotlinHarvester { return acc.defCount() ? acc.finish() : undefined; } + /** + * Def-ONLY facts for a value-position assignment carrier (`x = when (k) {…}`, + * #2205): just the LHS target, attached to the continuation block the branch + * arms rejoin. The branch subject + arm-value USES are already harvested onto + * the branch's own blocks, so this must NOT re-walk the RHS — only a plain `=` + * to a simple-identifier lvalue defines (a member / index target is not a + * scalar def; a compound `+=` is not a value-branch carrier). + */ + assignmentDefFacts(node: SyntaxNode): StatementFacts | undefined { + if (this.assignmentOperator(node) !== '=') return undefined; + const acc = new FactAccumulator(node.startPosition.row + 1); + const lvalue = node.namedChildren.find((c) => c.type === 'directly_assignable_expression'); + if (lvalue) { + const lv = this.unwrapAssignable(lvalue); + if (lv.type === 'simple_identifier') this.def(lv, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + /** ENTRY-block facts for the parameters (defs only). */ paramFacts(): StatementFacts | undefined { const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); diff --git a/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts b/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts index d1889a260..68ef9f118 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/kotlin.ts @@ -80,12 +80,14 @@ * Java/C#/TS over-approximation. * * Kotlin-specific modeling decisions (documented approximations): - * - `if` / `when` / `try` used as an EXPRESSION VALUE (assigned, returned inline, - * passed as an argument) is left INLINE inside its owning statement's block — - * its arms are not modeled as separate CFG blocks (the value flows to the - * consumer). Only a STATEMENT-position construct (a direct `statements` child) - * becomes a dispatch/branch construct. This mirrors the Java inline-value-switch - * gap — documented, not faked. + * - a value-position `if` (with `else`) / `when` (≥2 arms) / `try` IS modeled as + * control flow (#2205) in four carriers: a `val/var x = ` binding, an + * `x = ` assignment, a `return `, and a `fun f() = ` + * expression body — its arms become separate CFG blocks that rejoin at a + * binding/return continuation. A branch in any OTHER value position — nested in + * a call argument (`f(when …)`), a deeper subexpression — is left INLINE (the + * value flows to the consumer in one block). The ternary-like `?:` (elvis) and + * `?.` micro-branches are excluded by design. * - a `lambda_literal` / nested `anonymous_function` / nested * `function_declaration` is collected as its OWN function by `isFunction`, so * its body gets a standalone CFG; in the ENCLOSING function it is an opaque @@ -246,9 +248,10 @@ class KotlinCfgWalk { * Whether a statement breaks the current straight-line block. `if` / `when` / * `try` are EXPRESSIONS in Kotlin — they break a block when used as a STATEMENT * (a direct child of a `statements` list), OR when they are the value of a - * `val/var x = ` binding (#2205) — `visitStmt`'s `property_declaration` - * case then models the arms as control flow. Other value positions (an - * assignment RHS, a call argument) still coalesce — a remaining gap. + * `val/var x = ` binding or an `x = ` assignment (#2205) — + * `visitStmt`'s `property_declaration` / `assignment` case then models the arms + * as control flow. A call argument value position still coalesces (a remaining + * gap — the branch is nested in a call, harder to bind). */ private isControlFlow(stmt: SyntaxNode): boolean { if (stmt.type === 'label') return true; // queue label, emit no block @@ -256,6 +259,7 @@ class KotlinCfgWalk { const v = this.directValue(stmt); return v !== undefined && this.isModelableValueBranch(v); } + if (stmt.type === 'assignment') return this.assignmentBranch(stmt) !== undefined; if (!CONTROL_FLOW_TYPES.has(stmt.type)) return false; if (this.isExpressionConstruct(stmt.type)) return this.isStatementPosition(stmt); return true; @@ -309,6 +313,13 @@ class KotlinCfgWalk { if (value && this.isModelableValueBranch(value)) return this.visitBindBranch(stmt, value); return this.visitSimple(stmt); } + case 'assignment': { + // `x = when (k) { … }` / `x = if (c) a else b` / `x = try { … }` (#2205): + // model the RHS branch as control flow and bind the target on the rejoin. + const branch = this.assignmentBranch(stmt); + if (branch) return this.visitBindAssign(stmt, branch); + return this.visitSimple(stmt); + } default: return this.visitSimple(stmt); } @@ -345,11 +356,13 @@ class KotlinCfgWalk { /** `return [expr]` / `return@label` — threads through every active finalizer. */ private visitReturn(stmt: SyntaxNode): TraversalResult { - // `return when (k) { … }` / `return if (c) a else b` (#2205): the returned - // value is a value-position branch — model it as control flow, with each arm - // returning (its value IS the function result), threading finalizers per arm. + // `return when (k) { … }` / `return if (c) a else b` / `return try { … }` + // (#2205): the returned value is a value-position branch — model it as control + // flow, with each arm returning (its value IS the function result), threading + // finalizers per arm. const branch = stmt.namedChildren.find( - (c) => c.type === 'when_expression' || c.type === 'if_expression', + (c) => + c.type === 'when_expression' || c.type === 'if_expression' || c.type === 'try_expression', ); if (branch && this.isModelableValueBranch(branch)) { const res = this.visitBranchExpr(branch); @@ -470,16 +483,25 @@ class KotlinCfgWalk { return node.namedChildren.filter((c) => c.type === 'when_entry').length >= 2; } if (node.type === 'if_expression') return this.elseNodeOf(node) !== undefined; + // `val x = try { … } catch { … }` / `try { … } finally { … }` (#2205): a + // value-position `try` with a `catch` OR a `finally` is a real branch — its + // value is the body's value, a catch's value, or the body's value threaded + // through a finalizer — so model it as control flow. + if (node.type === 'try_expression') { + return node.namedChildren.some((c) => c.type === 'catch_block' || c.type === 'finally_block'); + } return false; } /** - * Model a value-position `when`/`if` as control flow regardless of its + * Model a value-position `when`/`if`/`try` as control flow regardless of its * statement/value position — {@link visitStmt}'s `isStatementPosition` gate keeps * value-position branches inline, so call the branch handlers directly here. */ private visitBranchExpr(node: SyntaxNode): TraversalResult { - return node.type === 'when_expression' ? this.visitWhen(node) : this.visitIf(node); + if (node.type === 'when_expression') return this.visitWhen(node); + if (node.type === 'try_expression') return this.visitTry(node) ?? this.visitSimple(node); + return this.visitIf(node); } /** @@ -502,6 +524,40 @@ class KotlinCfgWalk { return { entry: res.entry, exits: [cont] }; } + /** + * The value-position branch on a plain `=` assignment RHS (`x = when (k) {…}` / + * `x = if (c) a else b` / `x = try {…}`, #2205), or undefined. Only a plain `=` + * (not a compound `+=`) with a modelable-branch RHS qualifies. + */ + private assignmentBranch(stmt: SyntaxNode): SyntaxNode | undefined { + if (stmt.type !== 'assignment') return undefined; + const eq = stmt.children.find((c) => !c.isNamed && c.text === '='); + if (!eq) return undefined; // compound assignment (`+=` etc.) is not a carrier + const rhs = stmt.namedChildren.find( + (c) => c.type !== 'directly_assignable_expression' && !isComment(c), + ); + return rhs && this.isModelableValueBranch(rhs) ? rhs : undefined; + } + + /** + * `x = ` (#2205): visit the RHS branch as control flow, then rejoin its + * arms at a facts-only continuation carrying ONLY the LHS target def (the branch + * subject + arm-value uses are already on the branch's blocks). The arms are now + * control-dependent on the branch — mirrors the Ruby value-branch assignment. + */ + private visitBindAssign(stmt: SyntaxNode, branch: SyntaxNode): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.assignmentDefFacts(stmt), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + /** * A `fun f() = EXPR` expression body (#2205). A value-position branch is modeled * as control flow (each arm yields the returned function result); any other diff --git a/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts index 9bd680407..8e4062457 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/php-harvest.ts @@ -303,6 +303,24 @@ export class PhpHarvester { return acc.finish(); } + /** + * Def-ONLY facts for a value-position assignment carrier (`$x = match($v) {…}`, + * #2207): just the LHS target(s), attached to the continuation block the match + * arms rejoin. The match condition + arm-value USES are already harvested onto + * the branch's own blocks (visitMatch), so this must NOT re-walk the RHS. A + * member/subscript target (`$this->x = match …`) has no scalar def → undefined. + */ + assignmentDefFacts(assignExpr: SyntaxNode): StatementFacts | undefined { + const acc = new FactAccumulator(assignExpr.startPosition.row + 1); + const left = assignExpr.childForFieldName('left'); + if (left) { + const lv = this.unwrapParen(left); + if (lv.type === 'variable_name') this.def(lv, acc); + else if (lv.type === 'list_literal') for (const v of this.listTargets(lv)) this.def(v, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + /** Facts for a `foreach ($it as [$k =>] $v)` head: targets bind, iterable used. */ foreachHeadFacts(stmt: SyntaxNode): StatementFacts { const acc = new FactAccumulator(stmt.startPosition.row + 1); diff --git a/gitnexus/src/core/ingestion/cfg/visitors/php.ts b/gitnexus/src/core/ingestion/cfg/visitors/php.ts index f9a73c74f..3e4878513 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/php.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/php.ts @@ -44,8 +44,8 @@ * - loops (for / foreach / while / do-while) → `cond-true` / `loop-back` / * `cond-false` * - switch → `switch-case` / `fallthrough` (a `case` with no `break`/`return` - * falls through to the next case); `match` is left INLINE as a value - * (no fallthrough — see the limitations). + * falls through to the next case); a value-position `match` with ≥2 arms also + * dispatches as `switch-case` (no fallthrough), see the limitations. * - try/catch → `throw` (every protected-region block → the handler); a * `finally` runs on normal AND exception exit, so a `return`/`break`/`continue` * crossing it gets a `finally-*` completion edge. @@ -68,10 +68,12 @@ * region edges to the handler (an exception may fire mid-block). * * Known limitations: - * - `match` is a value-position EXPRESSION (`$r = match($x) { … }`), kept INLINE - * inside its owning statement's block — its arms are not modeled as separate - * CFG blocks (the value flows to the assignment). Documented gap, mirroring the - * Java inline-value-switch handling. + * - A value-position `match($x) { … }` with ≥2 arms IS modeled as a `switch-case` + * dispatch in two carriers (#2207): an `$x = match(…) {…}` assignment (arms + * rejoin at a binding continuation) and `return match(…) {…}` (each arm + * returns). A `match` in any OTHER position — a call argument, a nested + * subexpression — stays INLINE inside its owning block. The ternary `?:` is + * excluded by design (a micro-branch, like elvis in Kotlin). * - context-manager-style suppression and PHP's exception-from-mid-call outside * any `try` are not modeled (no edge), matching the other visitors. * - `goto` / named labels are modeled as straight-line blocks (the label is a @@ -192,7 +194,11 @@ class PhpCfgWalk { for (const stmt of stmts) { // An `expression_statement` wrapping a bare `throw_expression` is a // terminator (PHP has no `throw_statement` node), so it breaks the block. - const breaks = CONTROL_FLOW_TYPES.has(stmt.type) || this.isThrowStatement(stmt); + // An `$x = match($v) {…}` value-position assignment breaks too (#2207). + const breaks = + CONTROL_FLOW_TYPES.has(stmt.type) || + this.isThrowStatement(stmt) || + this.isValueBranchAssignment(stmt); if (breaks) { openSimple = undefined; // close any open straight-line block const res = this.visitStmt(stmt); @@ -232,6 +238,10 @@ class PhpCfgWalk { /** Dispatch one statement to its handler. Non-null except for empty blocks. */ visitStmt(stmt: SyntaxNode): SeqResult { if (this.isThrowStatement(stmt)) return this.visitThrow(stmt); + // `$x = match($v) { … };` (#2207): model the match arms as control flow and + // bind the assignment target on the rejoin. + const assign = this.assignmentBranch(stmt); + if (assign) return this.visitBindAssign(stmt, assign.expr, assign.match); switch (stmt.type) { case 'if_statement': return this.visitIf(stmt); @@ -285,6 +295,18 @@ class PhpCfgWalk { } private visitReturn(stmt: SyntaxNode): TraversalResult { + // `return match($v) { … };` (#2207): the returned value is a value-position + // branch — model it as control flow, with each arm returning (its value IS + // the function result), threading every active finally per arm. + const branch = stmt.namedChildren.find((c) => !isComment(c)); + if (branch && this.isModelableValueBranch(branch)) { + const res = this.visitBranchExpr(branch); + const finalizers = this.cfc.finalizersForReturn(); + for (const ex of res.exits) { + wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return'); + } + return { entry: res.entry, exits: [] }; + } const idx = this.builder.newBlock( startLineOf(stmt), endLineOf(stmt), @@ -670,6 +692,126 @@ class PhpCfgWalk { return group.childForFieldName('value') ?? undefined; } + // ── value-position match expression (#2207) ───────────────────────────────── + + /** + * The `{expr, match}` of an `$x = match($v) {…}` value-position assignment + * carrier, or undefined. `expr` is the `assignment_expression` (for the target + * def); `match` is the modelable `match_expression` RHS. Only a plain `=` + * assignment qualifies (an augmented `??=` etc. is not a value-branch bind). + */ + private assignmentBranch(stmt: SyntaxNode): { expr: SyntaxNode; match: SyntaxNode } | undefined { + if (stmt.type !== 'expression_statement') return undefined; + const expr = stmt.namedChildren.find((c) => !isComment(c)); + if (!expr || expr.type !== 'assignment_expression') return undefined; + const right = expr.childForFieldName('right'); + return right && this.isModelableValueBranch(right) ? { expr, match: right } : undefined; + } + + /** Whether a statement is an `$x = match(…) {…}` value-branch assignment. */ + private isValueBranchAssignment(stmt: SyntaxNode): boolean { + return this.assignmentBranch(stmt) !== undefined; + } + + /** + * Whether `node` is a value-position branch worth modeling as control flow + * (#2207): a `match_expression` with ≥2 arms — a real dispatch. PHP `match` is + * the only value-position branch (there is no `if`-expression); the ternary + * `?:` is deliberately excluded, like elvis in Kotlin. + */ + private isModelableValueBranch(node: SyntaxNode): boolean { + if (node.type !== 'match_expression') return false; + const block = node.childForFieldName('body'); + if (!block) return false; + return ( + block.namedChildren.filter( + (c) => c.type === 'match_conditional_expression' || c.type === 'match_default_expression', + ).length >= 2 + ); + } + + /** Model a value-position branch as control flow (only `match_expression`). */ + private visitBranchExpr(node: SyntaxNode): TraversalResult { + return this.visitMatch(node); + } + + /** + * Model a value-position `match($v) { c => v, default => v }` as a CFG dispatch: + * a discriminant block, each arm's value expression a block reached by a + * `switch-case` edge, all arms rejoining at one exit (no fallthrough — `match` + * never falls through). The arm condition lists are harvested as conditional + * uses on the dispatch (a later arm test runs only when earlier arms missed). + */ + private visitMatch(node: SyntaxNode): TraversalResult { + const condRaw = node.childForFieldName('condition'); + const cond = condRaw ? this.unwrapParen(condRaw) : node; + const dispatch = this.builder.newBlock( + startLineOf(node), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const matchExit = this.builder.newBlock(endLineOf(node), endLineOf(node), ''); + + const block = node.childForFieldName('body'); + const arms = block + ? block.namedChildren.filter( + (c) => c.type === 'match_conditional_expression' || c.type === 'match_default_expression', + ) + : []; + let hasDefault = false; + for (const arm of arms) { + const condList = arm.namedChildren.find((c) => c.type === 'match_condition_list'); + if (condList) this.builder.attachFacts(dispatch, this.harvest.factsConditional(condList)); + if (arm.type === 'match_default_expression') hasDefault = true; + const value = this.matchArmValue(arm); + const armBlock = this.builder.newBlock( + startLineOf(value ?? arm), + endLineOf(value ?? arm), + (value ?? arm).text, + 'normal', + value ? this.harvest.facts(value) : undefined, + ); + this.builder.edge(dispatch, armBlock, 'switch-case'); + this.builder.edge(armBlock, matchExit, 'seq'); + } + // `match` with no `default` throws `\UnhandledMatchError` on no match; keep + // EXIT reachable via a conservative no-match edge when no default arm exists. + if (!hasDefault) this.builder.edge(dispatch, matchExit, 'switch-case'); + + return { entry: dispatch, exits: [matchExit] }; + } + + /** The value (result) expression of a match arm — its LAST named child. */ + private matchArmValue(arm: SyntaxNode): SyntaxNode | undefined { + const named = arm.namedChildren.filter((c) => !isComment(c)); + return named[named.length - 1]; + } + + /** + * `$x = match($v) { … }` (#2207): visit the match as control flow, then rejoin + * its arms at a facts-only continuation carrying ONLY the LHS target def (the + * condition + arm-value uses are already on the match's blocks). The arms are + * now control-dependent on the dispatch — mirrors the Ruby value-branch assign. + */ + private visitBindAssign( + stmt: SyntaxNode, + assignExpr: SyntaxNode, + branch: SyntaxNode, + ): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.assignmentDefFacts(assignExpr), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + /** * try / catch / finally. A `finally` runs on BOTH normal and exception exit — * a `return`/`break`/`continue` crossing it threads through it (`finally-*` diff --git a/gitnexus/src/core/ingestion/cfg/visitors/swift-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/swift-harvest.ts index 7e17a6eee..e9bba8bee 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/swift-harvest.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/swift-harvest.ts @@ -279,6 +279,24 @@ export class SwiftHarvester { return acc.finish(); } + /** + * Def-ONLY facts for a value-position binding carrier (`let x = if … / switch …`, + * #2207): just the declared name pattern's leaves, attached to the continuation + * block the branch arms rejoin. The condition + arm-value USES are already + * harvested onto the branch's own blocks (visitIf / visitSwitch), so this must + * NOT re-walk the value — only the `name`-field pattern leaves are defs here. + */ + bindingDefFacts(stmt: SyntaxNode): StatementFacts | undefined { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + for (let i = 0; i < stmt.childCount; i++) { + if (stmt.fieldNameForChild(i) === 'name') { + const pat = stmt.child(i); + if (pat) this.defPattern(pat, acc); + } + } + return acc.defCount() ? acc.finish() : undefined; + } + /** * MAY-def facts for a `switch_pattern`'s value bindings (`case let n` / * `case .some(let v)`). The binding only takes effect when the case matches, diff --git a/gitnexus/src/core/ingestion/cfg/visitors/swift.ts b/gitnexus/src/core/ingestion/cfg/visitors/swift.ts index 57af723d4..050a48e39 100644 --- a/gitnexus/src/core/ingestion/cfg/visitors/swift.ts +++ b/gitnexus/src/core/ingestion/cfg/visitors/swift.ts @@ -88,6 +88,12 @@ * trailing closure, which is unwrapped to model scope-exit flow. * * Known limitations: + * - a value-position `if`/`switch` (Swift 5.9) IS modeled as control flow in two + * carriers (#2207): a `let x = if … else … / switch … {…}` binding (arms rejoin + * at a binding continuation) and `return if … / switch …` (each arm returns). + * tree-sitter-swift reuses `if_statement` / `switch_statement` for the value + * form. A value branch in any OTHER position (an argument, an interpolation) + * stays inline; the ternary `?:` / `??` are excluded by design. * - computed properties (`var y: Int { get { … } set { … } }`) have their bodies * inside `computed_getter` / `computed_setter` rather than a function node; v1 * does NOT build a CFG for them (documented gap, not faked). @@ -232,6 +238,12 @@ class SwiftCfgWalk { private isControlFlow(stmt: SyntaxNode): boolean { if (stmt.type === 'statement_label') return true; // queue label, emit no block if (this.isDeferCall(stmt)) return true; + // `let x = if … / switch …` (Swift 5.9, #2207): a value-position branch breaks + // so `visitStmt` models the arms as control flow instead of coalescing. + if (stmt.type === 'property_declaration') { + const v = this.directValue(stmt); + return v !== undefined && this.isModelableValueBranch(v); + } return CONTROL_FLOW_TYPES.has(stmt.type); } @@ -245,6 +257,13 @@ class SwiftCfgWalk { } if (this.isDeferCall(stmt)) return this.visitDefer(stmt); switch (stmt.type) { + case 'property_declaration': { + // `let x = if … / switch …` (Swift 5.9, #2207): the value is a value- + // position branch — model it as control flow and bind on the rejoin. + const value = this.directValue(stmt); + if (value && this.isModelableValueBranch(value)) return this.visitBindBranch(stmt, value); + return this.visitSimple(stmt); + } case 'if_statement': return this.visitIf(stmt); case 'guard_statement': @@ -308,6 +327,20 @@ class SwiftCfgWalk { /** `return [expr]` — threads through every active `defer` (LIFO) before EXIT. */ private visitReturn(stmt: SyntaxNode): TraversalResult { + // `return if … / switch …` (Swift 5.9, #2207): the returned value is a value- + // position branch — model it as control flow, with each arm returning (its + // value IS the function result), threading every active finalizer per arm. + const branch = stmt.namedChildren.find( + (c) => c.type === 'if_statement' || c.type === 'switch_statement', + ); + if (branch && this.isModelableValueBranch(branch)) { + const res = this.visitBranchExpr(branch); + const finalizers = this.cfc.finalizersForReturn(); + for (const ex of res.exits) { + wireJumpThroughFinalizers(this.builder, ex, finalizers, this.builder.exitIndex, 'return'); + } + return { entry: res.entry, exits: [] }; + } const idx = this.builder.newBlock( startLineOf(stmt), endLineOf(stmt), @@ -384,6 +417,58 @@ class SwiftCfgWalk { return labels; } + // ── value-position branches (#2207) ───────────────────────────────────────── + + /** + * The value-position branch of a `property_declaration` (`let x = if … / switch + * …`, Swift 5.9): the direct `if_statement` / `switch_statement` child (the value + * after `=`), or undefined. tree-sitter-swift reuses the statement nodes for the + * value form — there is no separate `if_expression` / `switch_expression`. + */ + private directValue(stmt: SyntaxNode): SyntaxNode | undefined { + return stmt.namedChildren.find( + (c) => c.type === 'if_statement' || c.type === 'switch_statement', + ); + } + + /** + * Whether `node` is a value-position branch worth modeling as control flow + * (#2207): an `if` with an `else` (a value-position `if` always has one), or a + * `switch` with ≥2 entries — a real dispatch. The ternary `?:` and `??` are + * excluded by design (micro-branches, like the Kotlin elvis). + */ + private isModelableValueBranch(node: SyntaxNode): boolean { + if (node.type === 'if_statement') return this.elseNodeOf(node) !== undefined; + if (node.type === 'switch_statement') { + return node.namedChildren.filter((c) => c.type === 'switch_entry').length >= 2; + } + return false; + } + + /** Model a value-position `if`/`switch` as control flow, bypassing position. */ + private visitBranchExpr(node: SyntaxNode): TraversalResult { + return node.type === 'switch_statement' ? this.visitSwitch(node) : this.visitIf(node); + } + + /** + * `let x = if … / switch …` (#2207): visit the branch as control flow, then + * rejoin its arms at a facts-only continuation carrying ONLY the bound name's + * def (the condition + arm-value uses are already on the branch's blocks). The + * arms are now control-dependent on the branch — mirrors Kotlin / Rust. + */ + private visitBindBranch(stmt: SyntaxNode, branch: SyntaxNode): TraversalResult { + const res = this.visitBranchExpr(branch); + const cont = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + '', + 'normal', + this.harvest.bindingDefFacts(stmt), + ); + this.builder.connect(res.exits, cont, 'seq'); + return { entry: res.entry, exits: [cont] }; + } + // ── branches ────────────────────────────────────────────────────────────── /** diff --git a/gitnexus/test/unit/cfg/csharp-visitor.test.ts b/gitnexus/test/unit/cfg/csharp-visitor.test.ts index 01b7b058c..dabd1792f 100644 --- a/gitnexus/test/unit/cfg/csharp-visitor.test.ts +++ b/gitnexus/test/unit/cfg/csharp-visitor.test.ts @@ -196,14 +196,67 @@ describe('C# CfgVisitor — switch', () => { expect(edgeKinds(cfg).has('switch-case')).toBe(true); }); - it('switch_expression arms each dispatch as a guarded branch (switch-case)', () => { + it('return switch_expression: each arm dispatches and returns the result (#2207)', () => { const cfg = cs.cfgOf( `class C { int M(int x) { return x switch { 1 => a(), 2 => b(), _ => c() }; } }`, ); - // The switch-expression lives inside the return block — it does not break a - // basic block, but the function still has a well-formed single-exit CFG. - expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); expect(edgeKinds(cfg).has('return')).toBe(true); + // every arm reaches EXIT (its value IS the returned result). + expect(reaches(cfg, block(cfg, 'a()'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'c()'), cfg.exitIndex)).toBe(true); + // a() does NOT fall into b() (arms never fall through). + expect(reaches(cfg, block(cfg, 'a()'), block(cfg, 'b()'))).toBe(false); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('value-position switch declaration is modeled, def bound at the join (#2207)', () => { + const cfg = cs.cfgOf( + `class C { int M(int x) { var y = x switch { 1 => a(), _ => b() }; use(y); return 0; } }`, + ); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // each arm rejoins and reaches the downstream use of the bound result. + expect(reaches(cfg, block(cfg, 'a()'), block(cfg, 'use(y);'))).toBe(true); + expect(reaches(cfg, block(cfg, 'b()'), block(cfg, 'use(y);'))).toBe(true); + const y = bindingIdx(cfg, 'y'); + expect(hasDef(cfg, y)).toBe(true); + expect(hasUse(cfg, y)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('expression-bodied member `=> k switch {…}` models the arms (#2207)', () => { + const cfg = cs.cfgOf(`class C { int G(int x) => x switch { 1 => a(), _ => b() }; }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(reaches(cfg, block(cfg, 'a()'), cfg.exitIndex)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('assignment-RHS / single-arm value switch stays inline (documented gap)', () => { + const assign = cs.cfgOf( + `class C { int M(int x) { int y = 0; y = x switch { 1 => 1, _ => 2 }; return y; } }`, + ); + expect(edgeKinds(assign).has('switch-case')).toBe(false); + expect(reaches(assign, assign.entryIndex, assign.exitIndex)).toBe(true); + + const oneArm = cs.cfgOf(`class C { int M(int x) { var y = x switch { _ => 0 }; return y; } }`); + expect(edgeKinds(oneArm).has('switch-case')).toBe(false); + }); + + it('non-exhaustive switch expression (no `_` arm) keeps a no-match edge (EXIT reachable) (#2211)', () => { + const cfg = cs.cfgOf( + `class C { int M(int x) { var y = x switch { 1 => a(), 2 => b() }; return y; } }`, + ); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + // 2 arms + the conservative no-match path = 3 switch-case successors from the dispatch. + const dispatchIdx = block(cfg, 'x'); + expect(cfg.edges.filter((e) => e.from === dispatchIdx && e.kind === 'switch-case').length).toBe( + 3, + ); }); }); @@ -414,6 +467,18 @@ describe('C# CfgVisitor — does not throw on exotic shapes', () => { warn.mockRestore(); } }); + + it('a truncated value-position switch never throws out of the carrier path (R4) (#2211)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = cs.parse(`class C { int M(int x) { var y = x switch { 1 => a(`); + for (const fn of cs.collectFunctions(root)) { + expect(() => createCsharpCfgVisitor().buildFunctionCfg(fn, 'f.cs')).not.toThrow(); + } + } finally { + warn.mockRestore(); + } + }); }); // U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no C# taint model diff --git a/gitnexus/test/unit/cfg/dart-visitor.test.ts b/gitnexus/test/unit/cfg/dart-visitor.test.ts index 8fa1c225b..1e44ad463 100644 --- a/gitnexus/test/unit/cfg/dart-visitor.test.ts +++ b/gitnexus/test/unit/cfg/dart-visitor.test.ts @@ -71,6 +71,13 @@ describe('Dart CfgVisitor — structure', () => { expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); }); + it('a truncated value-position switch never throws out of the carrier path (R4) (#2211)', () => { + const root = dart.parse(`int f(int v){ var x = switch (v) { 1 => a(`); + for (const fn of dart.collectFunctions(root)) { + expect(() => createDartCfgVisitor().buildFunctionCfg(fn, 'f.dart')).not.toThrow(); + } + }); + it('a class method is a CFG-bearing function and binds its params', () => { const cfg = dart.cfgOf(`class C { void m(int a) { g(a); } }`); expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); @@ -258,30 +265,89 @@ describe('Dart CfgVisitor — switch', () => { expect(cfg.edges.some((e) => e.from === tainted && e.to === sink)).toBe(false); }); - it('a switch EXPRESSION used as a value stays inline (no branch edges)', () => { + it('value-position switch declaration is modeled as a dispatch, def bound at the join (#2207)', () => { const cfg = dart.cfgOf(`void f(int x) { - var y = switch (x) { 1 => one(), 2 => two(), _ => other() }; + var y = switch (x) { 1 => one(x), 2 => two(), _ => other() }; use(y); }`); - // The value-position switch expression coalesces; no switch-case edges. - expect(edgeKinds(cfg).has('switch-case')).toBe(false); - expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); - expect(definesBinding(cfg, bindingIdx(cfg, 'y'))).toBe(true); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // each arm rejoins and reaches the downstream use of the bound result. + expect(reaches(cfg, block(cfg, 'one(x)'), block(cfg, 'use(y);'))).toBe(true); + expect(reaches(cfg, block(cfg, 'other()'), block(cfg, 'use(y);'))).toBe(true); + const y = bindingIdx(cfg, 'y'); + expect(definesBinding(cfg, y)).toBe(true); + expect(usesBinding(cfg, y)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); }); - it('a switch-EXPRESSION arm write is a may-def, not a hard kill of the prior def (#2206)', () => { + it('return switch (…) models each arm as returning the result (#2207)', () => { + const cfg = dart.cfgOf(`int f(int x) { + return switch (x) { 1 => a(x), 2 => b(), _ => c() }; + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(reaches(cfg, block(cfg, 'a(x)'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'c()'), cfg.exitIndex)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('a multi-binding decl with a switch-EXPRESSION value stays inline', () => { + const cfg = dart.cfgOf(`void f(int x) { + var y = switch (x) { _ => 0 }, z = 2; + use(y + z); + }`); + // Modeling a multi-binding decl arm-by-arm is out of scope — it coalesces. + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('an INLINE switch-EXPRESSION arm write is a may-def, not a hard kill (#2206)', () => { + // An argument-position switch expression is NOT a modeled value-branch carrier + // (#2207 models only declaration / return), so it coalesces — and the harvest + // must still treat each arm write as a MAY-def (only one arm runs). const cfg = dart.cfgOf(`void f(int x) { int z = 0; - var y = switch (x) { 1 => z = 10, _ => z = 20 }; - use(z); + use(switch (x) { 1 => z = 10, _ => z = 20 }); + sink(z); }`); const z = bindingIdx(cfg, 'z'); - // only one arm runs, so the arm writes (z=10 / z=20) are MAY-defs — they must - // not unconditionally KILL the prior `int z = 0`. + expect(edgeKinds(cfg).has('switch-case')).toBe(false); expect(cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(z)))).toBe( true, ); }); + + it('value switch without an unguarded `_` keeps the no-match edge (EXIT stays reachable) (#2211)', () => { + // A guarded `_ when …` is NOT an exhaustive catch-all — the conservative + // no-match path must remain (Dart throws at runtime if no arm + guard matches). + const cfg = dart.cfgOf(`int f(int v) { + return switch (v) { int n when n > 0 => a(n), _ when v < 0 => b() }; + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + // the dispatch must reach the join WITHOUT going through an arm (the no-match edge). + const dispatchIdx = block(cfg, 'switch v'); + const dispatchSucc = cfg.edges.filter( + (e) => e.from === dispatchIdx && e.kind === 'switch-case', + ); + // dispatch fans to 2 arms + the no-match join = 3 switch-case successors. + expect(dispatchSucc.length).toBe(3); + }); + + it('a value-switch `when` guard is a conditional dispatch use, not an arm-value use (#2211)', () => { + const cfg = dart.cfgOf(`int f(int v) { + var x = switch (v) { int n when guardOk(v) => a(n), _ => b() }; + use(x); + }`); + const vIdx = bindingIdx(cfg, 'v'); + // `v` (used by the guard `guardOk(v)`) is recorded as a use on the dispatch + // block (text `switch v`), not buried in an arm-value block. + const dispatch = cfg.blocks.find((b) => b.text === 'switch v')!; + expect(dispatch.statements?.some((s) => s.uses.includes(vIdx))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); }); describe('Dart CfgVisitor — try/on/catch/finally', () => { diff --git a/gitnexus/test/unit/cfg/java-visitor.test.ts b/gitnexus/test/unit/cfg/java-visitor.test.ts index 7070a9ee9..1cae93c04 100644 --- a/gitnexus/test/unit/cfg/java-visitor.test.ts +++ b/gitnexus/test/unit/cfg/java-visitor.test.ts @@ -294,13 +294,58 @@ describe('Java CfgVisitor — switch', () => { expect(hasUse(cfg, x)).toBe(true); }); - it('switch EXPRESSION value with yield stays inline; method has a single-exit CFG', () => { + it('value-position switch declaration is modeled as a dispatch, def bound at the join (#2207)', () => { const cfg = java.cfgOf(`class C { int m(int x) { int r = switch (x) { case 1 -> 10; default -> { yield 20; } }; - return r; + use(r); } }`); + // The arms are now real CFG blocks reached by switch-case dispatch edges. + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // Each arm rejoins and reaches the use of the bound result. + expect(reaches(cfg, block(cfg, '10'), block(cfg, 'use(r);'))).toBe(true); + expect(reaches(cfg, block(cfg, 'yield 20;'), block(cfg, 'use(r);'))).toBe(true); + // `r` is defined (at the continuation) and used downstream — the chain is live. + const r = bindingIdx(cfg, 'r'); + expect(hasDef(cfg, r)).toBe(true); + expect(hasUse(cfg, r)).toBe(true); + // Modeling the arms yields control dependence (the whole point of #2207). + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('return switch (…) {…} models each arm as returning the function result (#2207)', () => { + const cfg = java.cfgOf(`class C { int m(int x) { + return switch (x) { case 1 -> a(); case 2 -> b(); default -> c(); }; + } }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); expect(edgeKinds(cfg).has('return')).toBe(true); + // every arm reaches EXIT (its value IS the returned result). + expect(reaches(cfg, block(cfg, 'a()'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'c()'), cfg.exitIndex)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('value-position switch with ONE group stays inline (no real control dependence)', () => { + const cfg = java.cfgOf(`class C { int m(int x) { + int r = switch (x) { default -> 0; }; + use(r); + } }`); + // A single-arm switch carries no branch — it coalesces into the declaration block. + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('assignment-RHS value switch stays inline (documented remaining gap)', () => { + const cfg = java.cfgOf(`class C { int m(int x) { + int r = 0; + r = switch (x) { case 1 -> 10; default -> 20; }; + use(r); + } }`); + // Only declaration / return carriers are modeled; an assignment RHS coalesces. + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); }); it('statement switch with a yield arm builds a dispatch with a yield block', () => { @@ -313,6 +358,48 @@ describe('Java CfgVisitor — switch', () => { // statement-position switch breaks a block → switch-case dispatch edges. expect(edgeKinds(cfg).has('switch-case')).toBe(true); }); + + it('colon-form value switch: yield ends the arm, NO fallthrough; every arm is CDG-dependent (#2211)', () => { + const cfg = java.cfgOf(`class C { int m(int k) { + int x = switch (k) { case 1: yield one(); case 2: yield two(); default: yield zero(); }; + use(x); + } }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // a `yield` exits the switch — it does NOT fall through to the next colon group. + expect(edgeKinds(cfg).has('fallthrough')).toBe(false); + expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'two()'))).toBe(false); + // every arm rejoins and reaches the downstream use of the bound result. + expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'use(x);'))).toBe(true); + expect(reaches(cfg, block(cfg, 'two()'), block(cfg, 'use(x);'))).toBe(true); + // each arm is control-dependent on the dispatch — pin the SPECIFIC pairs. + const dispatch = block(cfg, 'k'); + const cdg = computeControlDependence(cfg); + expect( + cdg.edges.some( + (e) => e.controllerBlock === dispatch && e.dependentBlock === block(cfg, 'one()'), + ), + ).toBe(true); + expect( + cdg.edges.some( + (e) => e.controllerBlock === dispatch && e.dependentBlock === block(cfg, 'two()'), + ), + ).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('return switch (…) inside try/finally threads the finalizer per arm (#2211)', () => { + const cfg = java.cfgOf(`class C { int m(int k) { + try { + return switch (k) { case 1 -> a(); default -> b(); }; + } finally { cleanup(); } + } }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // each arm's return threads the finally before EXIT. + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + expect(reaches(cfg, block(cfg, 'a()'), block(cfg, 'cleanup();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'b()'), block(cfg, 'cleanup();'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); }); describe('Java CfgVisitor — try / catch / finally / try-with-resources', () => { @@ -494,6 +581,18 @@ describe('Java CfgVisitor — does not throw on exotic shapes', () => { warn.mockRestore(); } }); + + it('a truncated value-position switch never throws out of the carrier path (R4) (#2211)', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const root = java.parse(`class C { int m(int k){ int x = switch (k) { case 1 -> a(`); + for (const fn of java.collectFunctions(root)) { + expect(() => createJavaCfgVisitor().buildFunctionCfg(fn, 'f.java')).not.toThrow(); + } + } finally { + warn.mockRestore(); + } + }); }); // U6 — call-site `sites[]` taint substrate. INERT BY DESIGN: no Java taint model diff --git a/gitnexus/test/unit/cfg/kotlin-visitor.test.ts b/gitnexus/test/unit/cfg/kotlin-visitor.test.ts index 431565268..86efceeda 100644 --- a/gitnexus/test/unit/cfg/kotlin-visitor.test.ts +++ b/gitnexus/test/unit/cfg/kotlin-visitor.test.ts @@ -67,6 +67,13 @@ describe('Kotlin CfgVisitor — structure', () => { expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); }); + it('a truncated value-position when never throws out of the carrier path (R4) (#2211)', () => { + const root = kotlin.parse(`fun f(k: Int) { val x = when (k) { 0 ->`); + for (const fn of kotlin.collectFunctions(root)) { + expect(() => createKotlinCfgVisitor().buildFunctionCfg(fn, 'p.kt')).not.toThrow(); + } + }); + it('a class method is a CFG-bearing function', () => { const cfg = kotlin.cfgOf(`class C { fun m(a: Int) { g(a) } }`); expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); @@ -217,6 +224,78 @@ describe('Kotlin CfgVisitor — value-position branches (#2205)', () => { const cfg = kotlin.cfgOf(`fun f(k: Int) { val x = when (k) { else -> a() }; use(x) }`); expect(edgeKinds(cfg).has('switch-case')).toBe(false); }); + + it('x = when (...) assignment RHS models the arms; binds the target (#2205)', () => { + const cfg = kotlin.cfgOf( + `fun f(k: Int) { var x = 0; x = when (k) { 0 -> a(); else -> b() }; use(x) }`, + ); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'a()'), block(cfg, 'use(x)'))).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(usesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + }); + + it('x = if (c) ... else ... assignment RHS models both arms (#2205)', () => { + const cfg = kotlin.cfgOf(`fun f(c: Boolean) { var x = 0; x = if (c) a() else b(); use(x) }`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + }); + + it('val x = try { ... } catch { ... } models the value-position try (#2205)', () => { + const cfg = kotlin.cfgOf( + `fun f() { val x = try { risky() } catch (e: Exception) { fallback() }; use(x) }`, + ); + // the try/catch is modeled as control flow (a throw edge to the handler)… + expect(edgeKinds(cfg).has('throw')).toBe(true); + // …and is CDG-bearing, with x bound at the rejoin and used downstream. + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(usesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('x = try { ... } catch { ... } assignment RHS models the value-position try (#2205)', () => { + const cfg = kotlin.cfgOf( + `fun f() { var x = 0; x = try { risky() } catch (e: Exception) { fallback() }; use(x) }`, + ); + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(usesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('return try { ... } catch { ... } models the value-position try; each arm returns (#2205, #2211)', () => { + const cfg = kotlin.cfgOf( + `fun f(): Int { return try { risky() } catch (e: Exception) { fallback() } }`, + ); + // the value-position try is modeled as control flow (throw edge to the handler)… + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('fun f() = try { ... } catch { ... } expression body models the value-position try (#2205, #2211)', () => { + const cfg = kotlin.cfgOf(`fun f(): Int = try { risky() } catch (e: Exception) { fallback() }`); + // visitExprBody routes the value-position try through control flow (throw edge), + // each arm yielding the function result (return), CDG-bearing. + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('a compound `x += ...` / a plain call RHS stays inline (not a value-branch carrier)', () => { + const compound = kotlin.cfgOf(`fun f(k: Int) { var x = 0; x += k; use(x) }`); + expect(edgeKinds(compound).has('switch-case')).toBe(false); + const call = kotlin.cfgOf(`fun f(k: Int) { var x = 0; x = compute(k); use(x) }`); + expect(edgeKinds(call).has('switch-case')).toBe(false); + expect(edgeKinds(call).has('cond-true')).toBe(false); + }); }); describe('Kotlin CfgVisitor — loops', () => { diff --git a/gitnexus/test/unit/cfg/php-visitor.test.ts b/gitnexus/test/unit/cfg/php-visitor.test.ts index 8afae6a82..3d3d5cd02 100644 --- a/gitnexus/test/unit/cfg/php-visitor.test.ts +++ b/gitnexus/test/unit/cfg/php-visitor.test.ts @@ -193,15 +193,51 @@ describe('PHP CfgVisitor — switch / match', () => { expect(isExitReachableFromAllBlocks(cfg)).toBe(true); }); - it('match is a value expression (no fallthrough), kept inline — value flows to the assign', () => { - const cfg = php.cfgOf(wrap(`$r = match ($x) { 1, 2 => "low", default => "high" }; return $r;`)); - // match arms are NOT separate dispatch blocks (documented inline-value gap). + it('value-position match assignment dispatches; target bound at the join (#2207)', () => { + const cfg = php.cfgOf( + wrap(`$r = match ($x) { 1, 2 => low($x), default => high() }; use_it($r);`), + ); + // arms dispatch as switch-case, never fall through. + expect(edgeKinds(cfg).has('switch-case')).toBe(true); expect(edgeKinds(cfg).has('fallthrough')).toBe(false); - expect(edgeKinds(cfg).has('switch-case')).toBe(false); - // The match value and the return both reach EXIT. - expect(reaches(cfg, block(cfg, 'match ($x)'), cfg.exitIndex)).toBe(true); + // each arm rejoins and reaches the downstream use of the bound result. + expect(reaches(cfg, block(cfg, 'low($x)'), block(cfg, 'use_it($r)'))).toBe(true); + expect(reaches(cfg, block(cfg, 'high()'), block(cfg, 'use_it($r)'))).toBe(true); + const r = bindingIdx(cfg, '$r'); + expect(hasDef(cfg, r)).toBe(true); + expect(hasUse(cfg, r)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); expect(isExitReachableFromAllBlocks(cfg)).toBe(true); }); + + it('return match (…) models each arm as returning the result (#2207)', () => { + const cfg = php.cfgOf(wrap(`return match ($x) { 1 => a($x), 2 => b(), default => c() };`)); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(reaches(cfg, block(cfg, 'a($x)'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'c()'), cfg.exitIndex)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('single-arm match / ternary stays inline (no real control dependence)', () => { + const oneArm = php.cfgOf(wrap(`$r = match ($x) { default => 0 }; return $r;`)); + expect(edgeKinds(oneArm).has('switch-case')).toBe(false); + const ternary = php.cfgOf(wrap(`$r = $x > 0 ? a() : b(); return $r;`)); + expect(edgeKinds(ternary).has('switch-case')).toBe(false); + expect(isExitReachableFromAllBlocks(ternary)).toBe(true); + }); + + it('match without `default` keeps a no-match (UnhandledMatchError) edge; EXIT reachable (#2211)', () => { + const cfg = php.cfgOf(wrap(`$r = match ($x) { 1 => a($x), 2 => b() }; use_it($r);`)); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + // 2 arms + the conservative no-match path = 3 switch-case successors from the dispatch. + const dispatchIdx = block(cfg, '$x'); + expect(cfg.edges.filter((e) => e.from === dispatchIdx && e.kind === 'switch-case').length).toBe( + 3, + ); + }); }); describe('PHP CfgVisitor — try / catch / finally', () => { @@ -384,4 +420,11 @@ describe('PHP CfgVisitor — robustness', () => { expect(reachable(cfg, block(cfg, 'done()'))).toBe(true); expect(isExitReachableFromAllBlocks(cfg)).toBe(true); }); + + it('a truncated value-position match never throws out of the carrier path (R4) (#2211)', () => { + const root = php.parse(` a(`); + for (const fn of php.collectFunctions(root)) { + expect(() => createPhpCfgVisitor().buildFunctionCfg(fn, 'x.php')).not.toThrow(); + } + }); }); diff --git a/gitnexus/test/unit/cfg/swift-visitor.test.ts b/gitnexus/test/unit/cfg/swift-visitor.test.ts index 023596afc..a476c4eaa 100644 --- a/gitnexus/test/unit/cfg/swift-visitor.test.ts +++ b/gitnexus/test/unit/cfg/swift-visitor.test.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from 'vitest'; import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; import { createSwiftCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/swift.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; import { makeCfgHarness, type CfgHarness, @@ -54,6 +55,13 @@ describe('Swift CfgVisitor — structure', () => { expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); }); + it('a truncated value-position if never throws out of the carrier path (R4) (#2211)', () => { + const root = swift.parse(`func f(v: Int) { let x = if v > 0 {`); + for (const fn of swift.collectFunctions(root)) { + expect(() => createSwiftCfgVisitor().buildFunctionCfg(fn, 'f.swift')).not.toThrow(); + } + }); + it('init and deinit are CFG-bearing functions', () => { const cfgs = swift.cfgsOf(`class C { init(x: Int) { self.x = x } ; deinit { cleanup() } }`); expect(cfgs).toHaveLength(2); @@ -229,6 +237,70 @@ describe('Swift CfgVisitor — switch (no implicit fallthrough)', () => { }); }); +describe('Swift CfgVisitor — value-position if/switch (Swift 5.9, #2207)', () => { + const hasDef = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx))); + const hasUse = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx))); + + it('`let x = if … else …` is modeled as a branch; def bound at the join', () => { + const cfg = swift.cfgOf(`func f(v: Int) { + let x = if v > 0 { hi() } else { lo() } + use(x) + }`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(reaches(cfg, block(cfg, 'hi()'), block(cfg, 'use(x)'))).toBe(true); + expect(reaches(cfg, block(cfg, 'lo()'), block(cfg, 'use(x)'))).toBe(true); + const x = bindingIdx(cfg, 'x'); + expect(hasDef(cfg, x)).toBe(true); + expect(hasUse(cfg, x)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('`let y = switch v { … }` is modeled as a dispatch', () => { + const cfg = swift.cfgOf(`func f(v: Int) { + let y = switch v { case 1: one() ; default: other() } + use(y) + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'use(y)'))).toBe(true); + const y = bindingIdx(cfg, 'y'); + expect(hasDef(cfg, y)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('`return if … else …` models each arm as returning the result', () => { + const cfg = swift.cfgOf(`func f(v: Int) -> Int { + return if v > 0 { a() } else { b() } + }`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + expect(reaches(cfg, block(cfg, 'a()'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'b()'), cfg.exitIndex)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('an else-less `if` value / plain binding stays inline (no real control dependence)', () => { + // `let x = g()` is a plain binding — no branch. + const cfg = swift.cfgOf(`func f(v: Int) { let x = g()\n use(x) }`); + expect(edgeKinds(cfg).has('cond-true')).toBe(false); + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); + + it('a single-entry value switch stays inline (below the >= 2 modeling threshold) (#2211)', () => { + // `isModelableValueBranch` requires >= 2 `switch_entry`; a one-entry value + // switch carries no real control dependence, so the decl coalesces inline. + const cfg = swift.cfgOf(`func f(v: Int) { let x = switch v { default: g() }\n use(x) }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(false); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); +}); + describe('Swift CfgVisitor — do/catch (error handling)', () => { it('do/catch: a throw edge runs from each protected block to the handler', () => { const cfg = swift.cfgOf(`func f() { From cdb07289a4b239d738b4b5725d0092e40d68b030 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gerg=C5=91=20Magyar?= Date: Mon, 15 Jun 2026 19:16:53 +0100 Subject: [PATCH 3/3] perf(cfg): SSA-sparse reaching-defs to replace the dense-set worklist (#2201) (#2212) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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) * 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) * 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 * 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) * 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) * 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) * 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) * 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) * 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) * 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) --------- Co-authored-by: Claude Opus 4.8 (1M context) --- gitnexus/bench/cfg/baselines.json | 21 +- gitnexus/bench/cfg/measure.mjs | 108 ++- gitnexus/src/core/ingestion/cfg/emit.ts | 14 +- .../core/ingestion/cfg/reaching-defs-graph.ts | 318 +++++++ .../src/core/ingestion/cfg/reaching-defs.ts | 843 ++++++++++++++---- gitnexus/src/core/run-analyze.ts | 8 + gitnexus/src/storage/repo-manager.ts | 13 + .../cfg/reaching-defs-equivalence.test.ts | 613 +++++++++++++ gitnexus/test/unit/cfg/reaching-defs.test.ts | 111 +++ gitnexus/test/unit/pdg-mode-flip.test.ts | 38 + gitnexus/test/unit/run-analyze.test.ts | 5 + gitnexus/tsconfig.json | 1 + 12 files changed, 1903 insertions(+), 190 deletions(-) create mode 100644 gitnexus/src/core/ingestion/cfg/reaching-defs-graph.ts create mode 100644 gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts diff --git a/gitnexus/bench/cfg/baselines.json b/gitnexus/bench/cfg/baselines.json index 606ccd19e..975a20426 100644 --- a/gitnexus/bench/cfg/baselines.json +++ b/gitnexus/bench/cfg/baselines.json @@ -29,8 +29,25 @@ "scaling_budget": 1.8, "disk_bytes_budget": 1.2, "heap_budget": 1.3, - "rd_scaling_budget": 10.0, - "_note": "#2082 M2: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The overlay design measures rd ~5.2 normalized: the OUT spine copy on genning blocks is O(V) per block, which is quadratic when V scales with B (bounded in prod by maxFunctionLines; real functions have V~10-40). Budget 10 deliberately tolerates that known shape and exists to catch the repo's recurring per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16). If rd drops well below 5, tighten." + "rd_scaling_budget": 2.0, + "_note": "#2082 M2 / #2201 SSA: N bindings live across ~N blocks in one loop -- bindings x blocks scale JOINTLY (the solver-lattice stressor). The dense GEN/KILL worklist measured rd ~5.2 normalized here (the OUT spine copy is O(V) per block, quadratic when V scales with B). The #2201 SSA-sparse solver answers each use's reaching set from the def-use graph WITHOUT a per-block dense lattice, dropping rd to ~0.86 (linear; measured 5-23x faster absolute). Budget tightened 10->2: still absorbs noise + catches a regression to the per-item-rescan class (a per-use scan over all defs is O(n^3) here, ratio >=16), but now also catches a fall-back to the dense quadratic. Fingerprint unchanged -- CFG construction is untouched." + }, + "deep-nest": { + "fingerprint": "c0ca870487abc6ff379304c3162003e9e4f9b44aeb2fc29adfcf8d2179c7613a", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "rd_scaling_budget": 2.0, + "facts_large_min": 150, + "_note": "#2201: N nested loops carrying ONE variable end-to-end (depth 40->160) -- the pathology the dense worklist is superlinear on and whose block-visit total drives it past the blocks×64 ceiling (it would TRUNCATE to empty). rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget) to prove the ceiling stops firing: the depth-INDEPENDENT SSA solver (phi-nodes capture loop merges statically; no fixpoint iteration) computes the full facts (measured 164 at large) with rd_scaling ~0.68 (linear in depth; measured ~0.57ms at depth 160). facts_large_min tightened 100->150 (#2201 review R7): a partial-truncation regression that still cleared the old floor of 100 (but lost facts of the measured 164) now fails, with ~9% headroom under 164 for noise; the companion rd_all_computed gate also catches any non-'computed' status. rd_scaling_budget 2.0 catches a regression back to superlinear. No heap_budget -- the deep-nest CFG payload is tiny and the retained-heap delta is GC-noise-dominated. Re-baseline the fingerprint only on an intentional CFG/visitor change." + }, + "wide-merge": { + "fingerprint": "7a66a844ee3994bd930c1e34bad3d7b410a762220e927c94fb3787f38d745280", + "scaling_budget": 1.8, + "disk_bytes_budget": 1.2, + "heap_budget": 1.3, + "rd_scaling_budget": 2.0, + "facts_large_min": 24000, + "_note": "#2201 review R7: N bindings, EACH assigned in a 3-way branch (a wide multi-operand phi per binding) inside a loop, then all used after the merge. Distinct from dense-bindings (one CHAINED redef per `if`): every binding fans into its OWN wide phi, so this exercises phi-placement + renaming + the reachByScc condensation across MANY independent wide merges. N bindings x constant arms => O(N) facts (measured 26008 at the large size), so the gate is rd_scaling LINEARITY: measured ~1.07 (time 9.3->39.8ms over the 4x size step); budget 2.0 catches a regression to the per-binding-rescan O(N^2) class -- the recurring solver antipattern the reachByScc alias fast path (review R2) guards against. rd is measured under the PRODUCTION blocks×64 budget (rdProductionBudget): all functions report 'computed' (the SSA path does not truncate here), and facts_large_min 24000 (measured 26008, ~7% headroom) + the rd_all_computed gate assert the wide merges compute fully. fp_blocks 82 / fp_edges 112 at FP_SIZE=15. Re-baseline the fingerprint only on an intentional CFG/harvest-shape change." }, "fact-fanout": { "fingerprint": "83a8243a8aff117f69aeecb39d02a483e6cca70439d75f63e433f4e4ac85578f", diff --git a/gitnexus/bench/cfg/measure.mjs b/gitnexus/bench/cfg/measure.mjs index efc0661a8..209afae55 100644 --- a/gitnexus/bench/cfg/measure.mjs +++ b/gitnexus/bench/cfg/measure.mjs @@ -44,7 +44,10 @@ 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 } from '../../src/core/ingestion/cfg/emit.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'; @@ -172,6 +175,56 @@ const SCENARIOS = [ 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 — @@ -296,17 +349,32 @@ function measureCollect(tk, src, file, reps) { // 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. -function measureReachingDefs(cfgs, reps, maxFacts) { - for (const c of cfgs) computeReachingDefs(c, { maxFacts }); // warm JIT +// 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) facts += computeReachingDefs(c, { maxFacts }).facts.length; + 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 }; + return { ms: median(samples), facts, allComputed }; } // ---- taint pass cost (#2083 M3 U7) ---- @@ -441,10 +509,14 @@ function measureScenario(scenario) { ? heapLarge / heapSmall / sizeRatio : null; - // #2082 M2: reaching-defs solve cost over the same CFGs. + // #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 rdSmall = measureReachingDefs(small.cfgs, REPS, rdMaxFacts); - const rdLarge = measureReachingDefs(large.cfgs, REPS, rdMaxFacts); + 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; @@ -506,6 +578,7 @@ function measureScenario(scenario) { rd_scaling_ratio: Number(rdRatio.toFixed(3)), facts_small: rdSmall.facts, facts_large: rdLarge.facts, + rd_all_computed: rdLarge.allComputed, ...fingerprint(tk, scenario), }; } @@ -570,6 +643,25 @@ if (!CHECK) { `(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 ` + diff --git a/gitnexus/src/core/ingestion/cfg/emit.ts b/gitnexus/src/core/ingestion/cfg/emit.ts index 998f4c79e..f4550951d 100644 --- a/gitnexus/src/core/ingestion/cfg/emit.ts +++ b/gitnexus/src/core/ingestion/cfg/emit.ts @@ -108,10 +108,16 @@ export const DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION = * never fires). Truncation degrades to a sound empty REACHING_DEF for that one * function (status `truncated`), never wrong facts. * - * This ceiling is the SOUND backstop, not a perf fix: WTO / loop-aware iteration - * ordering was benchmarked and rejected (0% faster — the cost is dense-set - * propagation, not visitation order; see the no-go note in reaching-defs.ts at - * the RPO-order site). SSA-sparse reaching-defs is the deferred real fix. + * As of #2201 this ceiling is an adversarial-only backstop that effectively + * never fires on real code: the production solver auto-selects the SSA-sparse + * path for the looping functions that would breach it, and the SSA path has no + * fixpoint iteration (it answers reaching queries from the def-use graph in one + * pass) so it computes the full facts where the dense worklist would have + * truncated. The budget is still consulted on the dense fallback path (small / + * loop-free functions, and throw-edge / unreachable-block functions the SSA path + * does not model). WTO / loop-aware iteration ordering was benchmarked and + * rejected (0% faster — the cost was dense-set propagation, not visitation + * order); SSA-sparse was the real fix. See reaching-defs.ts. */ export const DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS = 64; diff --git a/gitnexus/src/core/ingestion/cfg/reaching-defs-graph.ts b/gitnexus/src/core/ingestion/cfg/reaching-defs-graph.ts new file mode 100644 index 000000000..5088b2770 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/reaching-defs-graph.ts @@ -0,0 +1,318 @@ +/** + * Pure graph sub-stages for the reaching-definitions solvers (#2201 review R4). + * + * Extracted from reaching-defs.ts to keep that module focused on the + * orchestrator, the dense oracle, the statement sweep, and the dispatcher. + * Everything here is a pure function of plain arrays — no CFG, no harvest, no + * solver state — so this module has NO dependency on reaching-defs.ts (a strict + * one-way import) and each stage is independently testable. The SSA pipeline + * (dominators → dominance frontiers → Tarjan SCC → reach-set condensation) + * implements Cooper-Harvey-Kennedy + Cytron + Tarjan; reverse-post-order, the + * loop-reachability check, and the def-set/lattice primitives are shared with + * the dense GEN/KILL solver and the dispatcher. + * + * These are held byte-identical to their former inline form by the differential + * equivalence fuzz (test/unit/cfg/reaching-defs-equivalence.test.ts) — any diff + * after extraction is an extraction bug, never the oracle. + */ + +/** def-site keys reaching a program point (see reaching-defs.ts). */ +type DefSet = Set; +/** bindingIdx → def-site keys (the dense solver's per-block lattice). */ +type Lattice = Map; + +/** + * RPO over blocks reachable from `entry`; unreachable blocks appended by index. + * Returns the order AND the reachability bitmap the DFS already computed, so a + * caller needing "is every block reachable?" reuses this pass instead of a + * separate BFS (#2201 review R8 — the SSA path's reachability gate). + * + * @internal + */ +export function reversePostOrder( + entry: number, + succs: readonly number[][], + n: number, +): { order: number[]; visited: boolean[] } { + const visited = new Array(n).fill(false); + const post: number[] = []; + // Iterative DFS with an explicit phase stack (children pushed in reverse so + // they pop in sorted order — determinism). + const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }]; + visited[entry] = true; + while (stack.length) { + const top = stack[stack.length - 1]; + const children = succs[top.node]; + if (top.childIdx < children.length) { + const next = children[top.childIdx]; + top.childIdx += 1; + if (!visited[next]) { + visited[next] = true; + stack.push({ node: next, childIdx: 0 }); + } + } else { + post.push(top.node); + stack.pop(); + } + } + const order = post.reverse(); + for (let b = 0; b < n; b++) if (!visited[b]) order.push(b); + return { order, visited }; +} + +/** + * Immediate dominators (Cooper-Harvey-Kennedy; correct on irreducible CFGs). + * `rpo` is the reverse-post-order rooted at the synthetic start `S`, `dPredsX` + * the dominator-graph predecessors (incl. S→entry). Returns idom[b] for every + * node in [0, nx); idom[S] === S. + * + * @internal + */ +export function buildDominators( + rpo: readonly number[], + dPredsX: readonly number[][], + S: number, + nx: number, +): number[] { + const rpoIdx = new Array(nx); + rpo.forEach((b, i) => (rpoIdx[b] = i)); + const idom = new Array(nx).fill(-1); + idom[S] = S; + const intersect = (a: number, b: number): number => { + while (a !== b) { + while (rpoIdx[a] > rpoIdx[b]) a = idom[a]; + while (rpoIdx[b] > rpoIdx[a]) b = idom[b]; + } + return a; + }; + for (let changed = true; changed; ) { + changed = false; + for (const b of rpo) { + if (b === S) continue; + let nd = -1; + for (const p of dPredsX[b]) if (idom[p] !== -1) nd = nd === -1 ? p : intersect(nd, p); + if (nd !== -1 && idom[b] !== nd) { + idom[b] = nd; + changed = true; + } + } + } + return idom; +} + +/** + * Dominance frontiers (Cytron). df[b] is the set of nodes where b's dominance + * ends — the φ-placement targets for any binding defined in b. + * + * @internal + */ +export function buildDominanceFrontiers( + dPredsX: readonly number[][], + idom: readonly number[], + nx: number, +): Set[] { + const df: Set[] = Array.from({ length: nx }, () => new Set()); + for (let b = 0; b < nx; b++) { + const dp = dPredsX[b]; + if (dp.length < 2) continue; + for (const p of dp) { + let runner = p; + while (runner !== idom[b] && runner !== -1) { + df[runner].add(b); + runner = idom[runner]; + } + } + } + return df; +} + +/** + * Tarjan strongly-connected components over the value-graph operand edges + * (`nodeOps[node]` = operand node ids). Iterative (explicit work stack — the + * graph can be deep). SCCs are emitted in REVERSE topological order, so an + * SCC's operand SCCs are numbered before it — the property + * {@link condenseReachingSets} relies on for its single forward pass. + * + * @internal + */ +export function tarjanScc(nodeOps: readonly number[][]): { + sccOf: number[]; + sccMembers: number[][]; +} { + const N = nodeOps.length; + const sccOf = new Array(N).fill(-1); + const sccMembers: number[][] = []; + const index = new Array(N).fill(-1); + const low = new Array(N).fill(0); + const onStk = new Array(N).fill(false); + const tarjanStk: number[] = []; + let counter = 0; + for (let start = 0; start < N; start++) { + if (index[start] !== -1) continue; + const work: { node: number; oi: number }[] = [{ node: start, oi: 0 }]; + index[start] = low[start] = counter++; + tarjanStk.push(start); + onStk[start] = true; + while (work.length) { + const top = work[work.length - 1]; + const ops = nodeOps[top.node]; + if (top.oi < ops.length) { + const w = ops[top.oi++]; + if (index[w] === -1) { + index[w] = low[w] = counter++; + tarjanStk.push(w); + onStk[w] = true; + work.push({ node: w, oi: 0 }); + } else if (onStk[w] && index[w] < low[top.node]) { + low[top.node] = index[w]; + } + } else { + if (low[top.node] === index[top.node]) { + const members: number[] = []; + let w: number; + do { + w = tarjanStk.pop()!; + onStk[w] = false; + sccOf[w] = sccMembers.length; + members.push(w); + } while (w !== top.node); + sccMembers.push(members); + } + work.pop(); + if (work.length) { + const par = work[work.length - 1].node; + if (low[top.node] < low[par]) low[par] = low[top.node]; + } + } + } + } + return { sccOf, sccMembers }; +} + +/** + * Reaching def-key set per SCC via condensation (cycle-safe union). Tarjan emits + * SCCs in reverse topological order, so a single forward pass over SCCs resolves + * every union: an SCC's reaching set is its members' own leaf keys plus the + * already-computed reaching sets of its cross-SCC operands. + * + * Alias fast path (#2201 review R2): an SCC with NO own leaf keys whose cross-SCC + * operands all resolve to a SINGLE source SCC has exactly that source's reaching + * set — share it BY REFERENCE instead of copying element-by-element (the O(defs²) + * cost at wide-fan-in φ merges). Safe: the returned sets are read-only after this + * pass, and contents are identical (set iteration order is irrelevant — the + * sweep sorts each use's keys before emission, KTD6). + * + * @internal + */ +export function condenseReachingSets( + sccMembers: readonly number[][], + sccOf: readonly number[], + nodeKeys: readonly (DefSet | null)[], + nodeOps: readonly number[][], +): DefSet[] { + const reachByScc: DefSet[] = new Array(sccMembers.length); + for (let s = 0; s < sccMembers.length; s++) { + const members = sccMembers[s]; + let aliasTarget = -1; // the unique cross-SCC source SCC, or -1 if none/many + let hasOwnKeys = false; + let multiSource = false; + for (const node of members) { + if (nodeKeys[node]) { + hasOwnKeys = true; + break; + } + for (const w of nodeOps[node]) { + const ws = sccOf[w]; + if (ws === s) continue; // intra-SCC operand: same set being built, adds nothing + if (aliasTarget === -1) aliasTarget = ws; + else if (aliasTarget !== ws) { + multiSource = true; + break; + } + } + if (multiSource) break; + } + if (!hasOwnKeys && !multiSource && aliasTarget !== -1) { + reachByScc[s] = reachByScc[aliasTarget]; // zero-copy share + continue; + } + // General case: union own leaf keys + every distinct cross-SCC operand set. + const set: DefSet = new Set(); + for (const node of members) { + const keys = nodeKeys[node]; + if (keys) for (const k of keys) set.add(k); + for (const w of nodeOps[node]) { + const ws = sccOf[w]; + if (ws !== s) for (const k of reachByScc[ws]) set.add(k); + } + } + reachByScc[s] = set; + } + return reachByScc; +} + +/** + * True iff a cycle is reachable from `entry` (the CFG has a loop). Iterative DFS + * with a gray/black coloring; a gray successor is a back-edge. O(V+E). Used by + * the production dispatcher to decide SSA-vs-dense. + * + * @internal + */ +export function hasReachableLoop(entry: number, succs: readonly number[][], n: number): boolean { + const color = new Uint8Array(n); // 0 white, 1 gray, 2 black + const stack: { node: number; i: number }[] = [{ node: entry, i: 0 }]; + color[entry] = 1; + while (stack.length) { + const top = stack[stack.length - 1]; + const ss = succs[top.node]; + if (top.i < ss.length) { + const next = ss[top.i++]; + if (color[next] === 1) return true; + if (color[next] === 0) { + color[next] = 1; + stack.push({ node: next, i: 0 }); + } + } else { + color[top.node] = 2; + stack.pop(); + } + } + return false; +} + +/** + * Order-stable union of two def-sets (shares `a` when `b` adds nothing). + * + * @internal + */ +export function unionSets(a: DefSet, b: DefSet): DefSet { + let target = a; + let copied = false; + for (const key of b) { + if (!target.has(key)) { + if (!copied) { + target = new Set(a); + copied = true; + } + target.add(key); + } + } + return target; +} + +/** + * Per-binding lattice equality with a reference fast path (sets only ever grow). + * + * @internal + */ +export function latticeEquals(a: Lattice, b: Lattice): boolean { + if (a === b) return true; + if (a.size !== b.size) return false; + for (const [k, bSet] of b) { + const aSet = a.get(k); + if (aSet === bSet) continue; + if (!aSet || aSet.size !== bSet.size) return false; + for (const v of bSet) if (!aSet.has(v)) return false; + } + return true; +} diff --git a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts index ff192e802..4e9f3c23b 100644 --- a/gitnexus/src/core/ingestion/cfg/reaching-defs.ts +++ b/gitnexus/src/core/ingestion/cfg/reaching-defs.ts @@ -1,8 +1,27 @@ /** - * Reaching definitions (#2082 M2 U3) — classic GEN/KILL monotone fixpoint over - * one function's CFG, plus the canonical intra-block statement sweep that - * recovers statement-granular def→use facts from M1's coalesced blocks - * WITHOUT re-splitting the CFG. + * Reaching definitions (#2082 M2 U3, SSA-sparse rewrite #2201) — per-function + * intraprocedural may-reaching-definitions, plus the canonical intra-block + * statement sweep that recovers statement-granular def→use facts from M1's + * coalesced blocks WITHOUT re-splitting the CFG. + * + * ARCHITECTURE (#2201): the analysis is split into solver-INDEPENDENT stages + * (shared by every path, so the byte-identical surface is maximal) and a + * swappable IN-set computation: + * - {@link harvestStatementFacts} — per-block GEN/allDefs + def/use telemetry. + * - {@link buildAdjacency} — throw-aware predecessor/successor adjacency. + * - the IN-set computer — answers block-entry reaching-set queries. Two + * implementations: {@link computeInSetsSparse} (SSA — CHK dominators → + * Cytron dominance frontiers + φ-placement → stack renaming over a + * synthetic entry, walked SCC-condensed) and {@link computeInSetsDense} + * (the original GEN/KILL worklist). Production runs {@link + * computeInSetsAuto}, which picks the SSA solver for looping functions large + * enough to amortize construction (where it is asymptotically faster and + * never hits the dense ceiling) and the dense worklist everywhere else; the + * dense path also serves the throw-edge / unreachable-block cases the SSA + * path does not model. The two are held byte-identical by the equivalence + * fuzz — only set CONTENTS must match (the sweep sorts each use's keys + * before the maxFacts cutoff, so iteration order is irrelevant). + * - {@link sweepFacts} — statement sweep + sort + maxFacts truncation. * * PURE AND DETERMINISTIC (load-bearing contract): * - Pure function of its inputs — no graph, no logger (warnings are the @@ -14,17 +33,10 @@ * insertion-ordered Maps/Sets throughout, and the output fact array is * explicitly sorted. Snapshot tests and content-derived edge ids rely on it. * - * COMPLEXITY DISCIPLINE (the four-times-repeated repo bug shape is per-item - * re-derivation inside the loop): def-sets are SHARED BY REFERENCE, never - * deep-copied — a MUST def's kill is total per binding, so a transfer either - * aliases the incoming set or replaces it; a MAY def (conditional context — - * see StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend. - * Single-predecessor blocks alias the predecessor's OUT map outright; - * multi-pred merges union only bindings whose incoming sets differ by - * reference. Iteration is reverse post-order, seeded with every block - * (unreachable blocks keep ⊥ IN — correct, their defs reach nothing). - * Convergence: sets grow monotonically within the finite def-site universe ⇒ - * ≤ loop-depth+1 passes in practice. + * COMPLEXITY DISCIPLINE: def-sets are SHARED BY REFERENCE, never deep-copied — + * a MUST def's kill is total per binding, so a transfer either aliases the + * incoming set or replaces it; a MAY def (conditional context — see + * StatementFacts.mayDefs) unions WITHOUT killing via a copy-on-extend. * * `limits.maxFacts` bounds materialization: facts are O(defs×uses) BY SPEC in * merge-heavy code (N branch-arm defs × N later uses = N² facts), and a @@ -34,6 +46,16 @@ * as a per-function taint-coverage gap. */ import type { BindingEntry, FunctionCfg } from './types.js'; +import { + buildDominanceFrontiers, + buildDominators, + condenseReachingSets, + hasReachableLoop, + latticeEquals, + reversePostOrder, + tarjanScc, + unionSets, +} from './reaching-defs-graph.js'; /** A statement-granular program point within one function's CFG. */ export interface ProgramPoint { @@ -68,18 +90,42 @@ export interface ReachingDefsLimits { */ readonly maxFacts?: number; /** - * Maximum total block dequeues in the dataflow fixpoint. Iterative - * reaching-defs on a reducible CFG converges in O(loop-nesting-depth) passes, - * so a worklist visits each block a small multiple of times for real code; a - * pathologically deep loop nest (machine-generated / obfuscated) drives the - * pass count — and thus the visit total — to O(blocks²) and the solver to - * seconds + GB of heap (`maxFacts` does not help: fact count stays linear). - * When the visit total exceeds this budget the fixpoint has NOT converged, so - * any facts would be unsound — the solver bails to a sound empty - * `status: 'truncated'` (like the `overflow` guard). `undefined`/0 ⇒ unlimited - * (the default for direct callers; the emit path sets a per-function budget). + * Adversarial-only safety bound on the DENSE worklist's iteration. + * + * The dense GEN/KILL solver reads this as a ceiling on total block dequeues: + * iterative reaching-defs on a reducible CFG converges in O(loop-nesting-depth) + * passes, but a pathologically deep loop nest drives the visit total — and thus + * the solver — to O(blocks²), seconds + GB of heap (`maxFacts` does not help: + * fact count stays linear). Exceeding the budget means the fixpoint has NOT + * converged, so any facts would be unsound — the dense solver bails to a sound + * empty `status: 'truncated'` (like the `overflow` guard). + * + * The SSA solver (#2201) has NO fixpoint iteration — it answers reaching + * queries from the def-use graph in one pass — so it always converges and this + * budget never trips it. The production dispatcher ({@link computeInSetsAuto}) + * routes the deep nests that would breach the dense ceiling to the SSA solver, + * which computes their full facts: the ceiling that fired on the dense worklist + * effectively never fires on real code (#2201 acceptance). The budget is still + * honored on the dense fallback path (small / loop-free functions, and the + * throw-edge / unreachable-block cases the SSA path does not model). + * + * `undefined`/0 ⇒ unlimited (the default for direct callers; the emit path sets + * a per-function budget). */ readonly maxBlockVisits?: number; + /** + * Memory bound on the SSA-sparse solver's value-graph construction (#2201 + * review R1). `maxFacts` bounds fact MATERIALIZATION (sweepFacts) but nothing + * bounds the φ/value-graph the sparse path builds first; a high-binding-density + * deep loop routed to SSA (≥ SSA_MIN_BLOCKS blocks + a reachable loop) builds an + * O(blocks×bindings) graph the dense path would have truncated at the + * `maxBlockVisits` ceiling (~1.5 GB measured on a 3000-block × 300-binding + * function). When the projected node count would exceed this, the sparse solver + * falls back to the dense oracle (byte-identical, and bounded — dense honors + * `maxBlockVisits`). Honored ONLY by the sparse path; the dense solver ignores + * it. `undefined`/0 ⇒ {@link DEFAULT_MAX_SSA_VALUE_GRAPH_NODES}. + */ + readonly maxSsaValueGraphNodes?: number; } export interface FunctionDefUse { @@ -111,7 +157,7 @@ export interface FunctionDefUse { * statements into one block, so an overflow would silently alias * (block b, stmt STRIDE+k) with (block b+1, stmt k) and fabricate wrong-block * facts. computeReachingDefs therefore range-checks up front and bails to a - * sound empty `truncated` result instead of ever letting a key alias. + * sound empty `overflow` result instead of ever letting a key alias. * 2^21 statements per block × blocks ≤ 2^32 stays inside Number's 2^53. */ const STMT_STRIDE = 1 << 21; @@ -124,11 +170,125 @@ type Lattice = Map; const EMPTY_LATTICE: Lattice = new Map(); +/** A block's GEN entry for one binding: the genned set + whether it kills. */ +interface GenEntry { + set: DefSet; + kills: boolean; +} + +/** Solver-independent per-block facts (shared by both IN-set computers). */ +interface Harvest { + /** gen[b]: bindingIdx → { set, kills }. A MUST def kills; a MAY def adds. */ + readonly gen: readonly (Map | null)[]; + /** allDefsGen[b]: bindingIdx → EVERY def-site key in the block (throw edges). */ + readonly allDefsGen: readonly (Lattice | null)[]; + readonly defLine: ReadonlyMap; + readonly defCount: number; + readonly useCount: number; +} + +/** Throw-aware adjacency (shared by both IN-set computers). */ +interface Adjacency { + readonly preds: readonly { from: number; viaThrow: boolean }[][]; + readonly succs: readonly number[][]; + /** Handlers whose IN depends on a block's IN (throw edges). */ + readonly throwSuccs: readonly number[][]; +} + +/** + * Block-entry reaching-set accessor: the set of def-site keys of `binding` + * reaching `blockIndex`'s entry, or undefined when none reach. Both solvers + * expose their result through this accessor so the sweep is solver-agnostic; + * the dense oracle backs it with precomputed per-block lattices, the sparse + * solver computes it lazily from the SSA def-use graph. Because {@link + * sweepFacts} sorts each use's reaching keys before the maxFacts cutoff, only + * the set CONTENTS need to match across solvers — not iteration order. + */ +type ReachingAt = (blockIndex: number, binding: number) => DefSet | undefined; + +/** + * The swappable stage: a block-entry reaching-set accessor, or a non- + * convergence signal (the work budget exceeded ⇒ sound empty `truncated`). + */ +type InSetsResult = { converged: true; reachingAt: ReachingAt } | { converged: false }; + +type InSetsComputer = ( + cfg: FunctionCfg, + n: number, + h: Harvest, + adj: Adjacency, + limits: ReachingDefsLimits | undefined, +) => InSetsResult; + /** * Compute reaching definitions for one function. See the module doc for the * purity/determinism/sharing contract. + * + * This is the production entry point. As of #2201 it auto-dispatches via + * {@link computeInSetsAuto} — the SSA-sparse solver ({@link computeInSetsSparse}) + * for looping functions large enough to amortize construction, the dense + * GEN/KILL worklist ({@link computeInSetsDense}) everywhere else (and for the + * throw-edge / unreachable-block functions the SSA path does not model). The two + * solvers are held byte-identical by the equivalence fuzz (status, bindings, + * sorted facts, def/use telemetry), so the dispatch is a pure performance + * heuristic; the dense solver doubles as that differential oracle. */ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimits): FunctionDefUse { + // #2201: production auto-selects the solver per function (see + // {@link computeInSetsAuto}) — the SSA solver where it pays off (looping + // functions large enough to amortize construction, incl. the deep nests the + // dense ceiling used to truncate), the dense worklist everywhere else (small + // or loop-free functions, where it is faster). Both are held byte-identical + // by the equivalence fuzz, so the choice is a pure performance heuristic. + return solveReachingDefs(cfg, limits, computeInSetsAuto); +} + +/** + * Dense GEN/KILL monotone worklist — the original (#2082 M2) reaching-defs + * solver. As of #2201 it plays two roles: (1) the production dispatcher + * ({@link computeInSetsAuto}) routes small / loop-free functions, and the + * throw-edge / unreachable-block functions the SSA path does not model, to this + * dense solver; (2) it is the differential equivalence ORACLE the fuzz checks + * the SSA path against. Keep it behavior-frozen — it is the ground truth. + * + * @internal exported for the equivalence fuzz harness (direct dense-vs-sparse + * comparison); the bench drives the production {@link computeReachingDefs}. + */ +export function computeReachingDefsDense( + cfg: FunctionCfg, + limits?: ReachingDefsLimits, +): FunctionDefUse { + return solveReachingDefs(cfg, limits, computeInSetsDense); +} + +/** + * SSA-sparse reaching-defs (#2201) — exposed directly so the equivalence fuzz + * can drive the SSA solver on every eligible CFG (bypassing the production + * size/loop dispatch heuristic in {@link computeInSetsAuto}) and assert + * byte-identity against the dense oracle. See {@link computeInSetsSparse} for + * the algorithm and byte-identical contract. + * + * @internal exported only for the equivalence fuzz harness. + */ +export function computeReachingDefsSparse( + cfg: FunctionCfg, + limits?: ReachingDefsLimits, +): FunctionDefUse { + return solveReachingDefs(cfg, limits, computeInSetsSparse); +} + +/** + * Shared orchestrator: the no-facts / overflow guards, the harvest, the + * adjacency build, the swappable IN-set computation, and the statement sweep. + * Only `computeInSets` differs between the production (sparse) and oracle + * (dense) paths — everything else is identical, which is what makes the two + * byte-identical by construction. + */ +function solveReachingDefs( + cfg: FunctionCfg, + limits: ReachingDefsLimits | undefined, + computeInSets: InSetsComputer, +): FunctionDefUse { if (!cfg.bindings) { return { status: 'no-facts', bindings: [], facts: [], defCount: 0, useCount: 0 }; } @@ -146,51 +306,46 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit } } - // ── adjacency (sorted for deterministic merges) ───────────────────────── - // A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not - // OUT: an exception can fire BEFORE the block's defs complete (the seed def - // in `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the - // sink) AND between any two defs of a multi-def coalesced block (the parse - // def in `x = parse(a); x = normalize(x);` is live exactly when normalize - // throws — OUT's last-def-wins misses it). Sound over-approximation; - // monotone, so the fixpoint absorbs it. See mergePreds. - const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []); - const succs: number[][] = Array.from({ length: n }, () => []); - // Handlers whose IN depends on this block's IN (throw edges) — requeued on - // IN change, since a genned binding can absorb IN growth without changing - // OUT, which would otherwise leave the handler stale. - const throwSuccs: number[][] = Array.from({ length: n }, () => []); - for (const e of cfg.edges) { - // Optional-chained pushes drop out-of-range endpoints defensively — the - // emit path validates via isEmitSafeCfg, but this pure function also runs - // on hand-built CFGs. - succs[e.from]?.push(e.to); - preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' }); - if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to); + const h = harvestStatementFacts(blocks, n); + const adj = buildAdjacency(cfg, n); + const solved = computeInSets(cfg, n, h, adj, limits); + if (!solved.converged) { + // Did NOT converge within the budget — the in-sets are not at the fixpoint, + // so any facts would be unsound. Bail to a sound empty `truncated` result + // (a coverage gap, not an error), carrying the def/use telemetry gathered. + return { + status: 'truncated', + bindings: cfg.bindings, + facts: [], + defCount: h.defCount, + useCount: h.useCount, + }; } - for (const list of preds) { - list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow)); - // duplicate (from, throw+non-throw) pairs both survive — the throw leg - // adds IN(from); the merge dedups set-wise. - } - for (const list of succs) list.sort((a, b) => a - b); - // ── per-block GEN + def/use telemetry ──────────────────────────────────── - // gen[b]: bindingIdx → { set, kills }. A MUST def resets the accumulated - // set (kill is total); a MAY def (conditionally-evaluated context — see - // StatementFacts.mayDefs) only ADDS: the binding's incoming defs survive, - // so the transfer is out[x] = kills ? set : in[x] ∪ set. - interface GenEntry { - set: DefSet; - kills: boolean; - } + const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity; + const { facts, truncated } = sweepFacts(blocks, solved.reachingAt, h.defLine, maxFacts); + + return { + status: truncated ? 'truncated' : 'computed', + bindings: cfg.bindings, + facts, + defCount: h.defCount, + useCount: h.useCount, + }; +} + +/** + * Per-block GEN + def/use telemetry. gen[b]: bindingIdx → { set, kills }. A + * MUST def resets the accumulated set (kill is total); a MAY def (conditionally- + * evaluated context — see StatementFacts.mayDefs) only ADDS: the binding's + * incoming defs survive, so the transfer is out[x] = kills ? set : in[x] ∪ set. + * allDefsGen[b] is what a throw edge delivers to its handler: an exception can + * fire between any two statements, so every intermediate def may be the live one + * at the handler — IN∪OUT alone misses defs overwritten later in the same + * coalesced block. + */ +function harvestStatementFacts(blocks: FunctionCfg['blocks'], n: number): Harvest { const gen: (Map | null)[] = new Array(n).fill(null); - // allDefsGen[b]: bindingIdx → EVERY def-site key in the block (must + may). - // This is what a throw edge delivers to its handler: an exception can fire - // between any two statements, so every intermediate def may be the live one - // at the handler — IN∪OUT alone misses defs overwritten later in the same - // coalesced block (`try { x = parse(a); x = normalize(x); } catch { sink(x) }` - // — parse's value is exactly what sink sees when normalize throws). const allDefsGen: (Lattice | null)[] = new Array(n).fill(null); const defLine = new Map(); // defKey → source line let defCount = 0; @@ -225,31 +380,73 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit gen[b.index] = g; allDefsGen[b.index] = all; } + return { gen, allDefsGen, defLine, defCount, useCount }; +} - // ── iteration order: RPO over reachable blocks, then the rest by index ── - // WTO / loop-aware iteration (Bourdoncle 1993) was evaluated as a fix for the - // O(blocks²) deep-loop-nest blow-up and REJECTED: on the dense-loop benchmark a - // faithful weak-topological-order solver was 104/104 byte-identical to this RPO - // worklist but 0% faster. The cost is inherent to dense-set propagation + - // lattice merges on the iterated dominance frontier, not to visitation order, so - // re-ordering passes buys nothing; the "skip re-evaluating a loop body once its - // header stabilises" shortcut is additionally unsound on irreducible (goto) - // CFGs. The sound, shipped backstop is the maxBlockVisits ceiling below (a - // blocks×64 budget — see emit.ts DEFAULT_PDG_MAX_REACHING_DEF_BLOCK_REVISITS), - // which truncates the pathological nest to a sound-empty result. The only real - // asymptotic fix is SSA-sparse reaching-defs (propagate along def-use chains, not - // dense block sets) — deferred to a tracked follow-up, not a reordering tweak. - const order = reversePostOrder(cfg.entryIndex, succs, n); +/** + * Throw-aware predecessor/successor adjacency, sorted for deterministic merges. + * A `throw` edge contributes IN(from) ∪ allDefs(from) to its handler, not OUT: + * an exception may fire BEFORE the block's defs complete (the seed def in + * `let x = seed(); try { x = risky(); } catch { sink(x) }` must reach the sink) + * AND between any two defs of a multi-def coalesced block. Sound over- + * approximation; monotone, so the fixpoint absorbs it. See mergePreds. + */ +function buildAdjacency(cfg: FunctionCfg, n: number): Adjacency { + const preds: { from: number; viaThrow: boolean }[][] = Array.from({ length: n }, () => []); + const succs: number[][] = Array.from({ length: n }, () => []); + // Handlers whose IN depends on this block's IN (throw edges) — requeued on + // IN change, since a genned binding can absorb IN growth without changing + // OUT, which would otherwise leave the handler stale. + const throwSuccs: number[][] = Array.from({ length: n }, () => []); + for (const e of cfg.edges) { + // Optional-chained pushes drop out-of-range endpoints defensively — the + // emit path validates via isEmitSafeCfg, but this pure function also runs + // on hand-built CFGs. + succs[e.from]?.push(e.to); + preds[e.to]?.push({ from: e.from, viaThrow: e.kind === 'throw' }); + if (e.kind === 'throw') throwSuccs[e.from]?.push(e.to); + } + for (const list of preds) { + list.sort((a, b) => a.from - b.from || Number(a.viaThrow) - Number(b.viaThrow)); + // duplicate (from, throw+non-throw) pairs both survive — the throw leg + // adds IN(from); the merge dedups set-wise. + } + for (const list of succs) list.sort((a, b) => a - b); + return { preds, succs, throwSuccs }; +} + +/** + * DENSE IN-set computer — the original monotone GEN/KILL worklist. Iterates in + * reverse post-order, seeded with every block (unreachable blocks keep ⊥ IN — + * correct, their defs reach nothing). Convergence: sets grow monotonically + * within the finite def-site universe ⇒ ≤ loop-depth+1 passes in practice. + * + * WTO / loop-aware iteration (Bourdoncle 1993) was evaluated as a fix for the + * O(blocks²) deep-loop-nest blow-up and REJECTED (#2195): on the dense-loop + * benchmark a faithful weak-topological-order solver was 104/104 byte-identical + * but 0% faster — the cost is inherent to dense-set propagation + lattice + * merges, not visitation order. The asymptotic fix shipped in #2201: the + * SSA-sparse solver ({@link computeInSetsSparse}). This dense version is retained + * only as the differential equivalence oracle the fuzz checks SSA against. + * + * @internal + */ +function computeInSetsDense( + cfg: FunctionCfg, + n: number, + h: Harvest, + adj: Adjacency, + limits: ReachingDefsLimits | undefined, +): InSetsResult { + const { gen, allDefsGen } = h; + const { preds, succs, throwSuccs } = adj; + const { order } = reversePostOrder(cfg.entryIndex, succs, n); - // ── fixpoint ──────────────────────────────────────────────────────────── const inSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE); const outSets: Lattice[] = new Array(n).fill(EMPTY_LATTICE); const inWorklist = new Array(n).fill(true); let pending = n; - // Fixpoint-iteration ceiling (see ReachingDefsLimits.maxBlockVisits): bound the - // total block dequeues so a pathologically deep loop nest can't drive the - // worklist to O(blocks²). undefined/0 ⇒ unlimited. const maxBlockVisits = limits?.maxBlockVisits && limits.maxBlockVisits > 0 ? limits.maxBlockVisits : Infinity; let blockVisits = 0; @@ -258,13 +455,7 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit if (!inWorklist[b]) continue; inWorklist[b] = false; pending -= 1; - if (++blockVisits > maxBlockVisits) { - // Did NOT converge within the budget — the in/out sets are not at the - // fixpoint, so any facts would be unsound. Bail to a sound empty - // `truncated` result (a coverage gap, not an error), carrying the def/use - // telemetry already gathered. - return { status: 'truncated', bindings: cfg.bindings, facts: [], defCount, useCount }; - } + if (++blockVisits > maxBlockVisits) return { converged: false }; const p = preds[b]; const inB: Lattice = @@ -309,17 +500,367 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit } } - // ── statement sweep: recover statement-granular def→use facts ─────────── - const maxFacts = limits?.maxFacts && limits.maxFacts > 0 ? limits.maxFacts : Infinity; + return { converged: true, reachingAt: (blockIndex, binding) => inSets[blockIndex]?.get(binding) }; +} + +/** + * SPARSE IN-set computer (#2201) — the production solver. Instead of the dense + * GEN/KILL worklist's per-block lattice fixpoint, it builds pruned SSA for the + * function (Cooper-Harvey-Kennedy dominators → Cytron dominance frontiers and + * φ-placement → stack-based renaming) and answers block-entry reaching-def + * queries by walking the SSA def-use graph. φ-nodes statically capture loop + * merges, so a use's reaching set is recovered without iterating the loop + * (depth-independent), and pass-through blocks carry the dominating definition + * via the rename stack rather than re-materializing a dense lattice at every + * block — the two effects that make it faster than the dense solver on the + * deep-nest and dense-bindings pathologies. + * + * BYTE-IDENTICAL CONTRACT: it computes the same may-reaching-definition SET at + * each block entry as {@link computeInSetsDense}. Order does not matter — {@link + * sweepFacts} sorts each use's reaching keys before the maxFacts cutoff (#2201 + * KTD6) — so only set CONTENTS must match; the equivalence fuzz holds the line. + * + * SCOPE (KTD4): the SSA path covers fully-reachable CFGs with kill/may-def + * transfers, reducible AND irreducible (CHK + Cytron are correct on irreducible + * graphs). It does NOT model throw edges' IN∪allDefs handler semantics or + * propagation among unreachable blocks; functions with either are routed to the + * dense oracle — byte-identical and correct, just not asymptotically faster. + * These are not the perf pathologies (deep nests / dense-bindings are + * throw-free and fully reachable), so the win lands where it matters. + * + * No fixpoint iteration ⇒ the solve always converges in O(program); the + * `maxBlockVisits` ceiling that fired on the dense worklist's deep nests never + * fires here (#2201 acceptance). The bound is honored only on the dense + * fallback path. + * + * @internal + */ +function computeInSetsSparse( + cfg: FunctionCfg, + n: number, + h: Harvest, + adj: Adjacency, + limits: ReachingDefsLimits | undefined, +): InSetsResult { + const nBindings = cfg.bindings?.length ?? 0; + if (nBindings === 0) return { converged: true, reachingAt: () => undefined }; + + const { gen } = h; + const { preds, succs, throwSuccs } = adj; + const entry = cfg.entryIndex; + + // Gate to the dense oracle for the shapes the SSA path does not model. + for (const list of throwSuccs) if (list.length) return computeInSetsDense(cfg, n, h, adj, limits); + // Malformed-input guard: an out-of-range binding index (negative or + // ≥ nBindings — a corrupted/stale durable parsedfile store) would crash the + // SSA path's nBindings-sized arrays (defBlocks[v]/stacks[u]). The dense solver + // tolerates any index (its lattice is a Map), so fall back — keeping the two + // byte-identical AND preserving the graceful per-function degradation the + // dense path gave (a throw here would escape the unguarded taint/harvest call + // sites and lose the whole file's taint layer). See hasEmitSafeFacts (emit.ts). + for (const b of cfg.blocks) { + const stmts = b.statements; + if (!stmts) continue; + for (const s of stmts) { + for (const d of s.defs) + if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits); + for (const u of s.uses) + if (u < 0 || u >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits); + if (s.mayDefs) + for (const d of s.mayDefs) + if (d < 0 || d >= nBindings) return computeInSetsDense(cfg, n, h, adj, limits); + } + } + // Synthetic pre-entry block (#2201): textbook SSA construction assumes the + // entry has no predecessors. A loop back-edge into the entry — or a self-loop + // on it — makes the entry a merge that needs a φ, and the dominance-frontier + // walk degenerates when idom[entry] === entry (it never lands the entry in its + // own frontier). A virtual start node S → entry (S itself has no preds) + // restores the invariant: idom[entry] = S, the entry joins {start ⊔ + // back-edges}, and the implicit start operand contributes ⊥ (an empty rename + // stack). S carries no statements, gen, or uses and is never queried. + const S = n; + const nx = n + 1; + const succsX: number[][] = new Array(nx); + for (let b = 0; b < n; b++) succsX[b] = succs[b] as number[]; + succsX[S] = [entry]; + const dPredsX: number[][] = new Array(nx); + for (let b = 0; b < n; b++) { + // preds[b] is pre-sorted by `from` (buildAdjacency), so duplicate `from` + // values (a throw + non-throw edge to the same handler, or parallel edges) + // are ADJACENT — dedup by skipping consecutive equals instead of a per-block + // Set + spread + sort (#2201 review R9). S = n exceeds every block index, so + // appending it for the entry keeps the list ascending without a re-sort. + const list: number[] = []; + let last = -1; + for (const p of preds[b]) { + if (p.from !== last) { + list.push(p.from); + last = p.from; + } + } + if (b === entry) list.push(S); + dPredsX[b] = list; + } + dPredsX[S] = []; + + // ── dominators (Cooper-Harvey-Kennedy; correct on irreducible CFGs) ── + // RPO rooted at the synthetic entry. `reachX` is the reachability the DFS + // already computed — reused for the unreachable-block gate below instead of a + // separate BFS (#2201 review R8). Because S→entry is S's only edge, reachX[b] + // (b []); + for (let b = 0; b < n; b++) { + const g = gen[b]; + if (g) for (const v of g.keys()) defBlocks[v].push(b); + } + + // ── value-graph nodes: leaves carry def-site keys; internal nodes (φ / + // may-def union) carry operand node ids. reachingSet(node) = union of all + // leaf keys reachable through operands (computed once, cycle-safe, below). + const nodeKeys: (DefSet | null)[] = []; + const nodeOps: number[][] = []; + const newLeaf = (keys: DefSet): number => ( + nodeKeys.push(keys), + nodeOps.push([]), + nodeKeys.length - 1 + ); + const newInternal = (): number => (nodeKeys.push(null), nodeOps.push([]), nodeKeys.length - 1); + + // ── φ-placement: φ for v at the iterated dominance frontier of v's defs ── + const phiNode: (Map | null)[] = new Array(nx).fill(null); + for (let v = 0; v < nBindings; v++) { + const dB = defBlocks[v]; + if (dB.length === 0) continue; + const placed = new Set(); + const inWork = new Set(dB); + const work = [...dB]; + while (work.length) { + const x = work.pop()!; + for (const y of df[x]) { + if (placed.has(y)) continue; + placed.add(y); + let m = phiNode[y]; + if (!m) phiNode[y] = m = new Map(); + m.set(v, newInternal()); + if (!inWork.has(y)) { + inWork.add(y); + work.push(y); + } + } + } + } + + // ── memory bound (#2201 review R1): cap the value graph, else fall back ── + // After φ-placement, nodeKeys.length == the φ-node count — the term that grows + // superlinearly with the input on the deep-loop / dense-binding pathology. + // Renaming below adds at most ~2 nodes per gen entry (already bounded by the + // def-site universe the STMT_STRIDE overflow guard caps). If the projected + // total would exceed the budget, fall back to the dense oracle here — BEFORE + // paying for renaming + Tarjan SCC on a blown-up graph. Byte-identical (dense + // is the equivalence oracle) and bounded (dense honors maxBlockVisits). Mirrors + // the throw-edge / unreachable / OOB-binding gates at the top of this function. + const nodeBudget = + limits?.maxSsaValueGraphNodes && limits.maxSsaValueGraphNodes > 0 + ? limits.maxSsaValueGraphNodes + : DEFAULT_MAX_SSA_VALUE_GRAPH_NODES; + let projectedRenameNodes = 0; + for (let b = 0; b < n; b++) projectedRenameNodes += (gen[b]?.size ?? 0) * 2; + if (nodeKeys.length + projectedRenameNodes > nodeBudget) { + return computeInSetsDense(cfg, n, h, adj, limits); + } + + // ── renaming (iterative dominator-tree DFS, per-binding value stacks) ── + const domChildren: number[][] = Array.from({ length: nx }, () => []); + for (let b = 0; b < nx; b++) if (b !== S && idom[b] !== -1) domChildren[idom[b]].push(b); + for (const list of domChildren) list.sort((a, b) => a - b); + + const stacks: number[][] = Array.from({ length: nBindings }, () => []); + const entryValue: (Map | null)[] = new Array(nx).fill(null); + + const enterBlock = (b: number): number[] => { + const pushed: number[] = []; + const pm = phiNode[b]; + if (pm) + for (const [v, node] of pm) { + stacks[v].push(node); + pushed.push(v); + } + // record block-entry (IN) value for each binding USED here — after φ push, + // before this block's own gen (the sweep applies intra-block defs itself). + // The synthetic entry S has no block ⇒ no statements/gen/uses. + const stmts = cfg.blocks[b]?.statements; + if (stmts) { + let ev: Map | null = null; + for (const s of stmts) + for (const u of s.uses) { + const st = stacks[u]; + if (st.length) { + if (!ev) ev = new Map(); + ev.set(u, st[st.length - 1]); + } + } + entryValue[b] = ev; + } + // apply block gen ⇒ OUT values that flow to successors + const g = gen[b]; + if (g) + for (const [v, ge] of g) { + const st = stacks[v]; + let node: number; + if (ge.kills) { + node = newLeaf(ge.set); + } else { + node = newInternal(); + if (st.length) nodeOps[node].push(st[st.length - 1]); // prior reaching (may-def keeps it) + nodeOps[node].push(newLeaf(ge.set)); + } + st.push(node); + pushed.push(v); + } + // fill successor φ operands with this block's current OUT for each φ binding + for (const s of succsX[b]) { + const sm = phiNode[s]; + if (!sm) continue; + for (const [v, phi] of sm) { + const st = stacks[v]; + if (st.length) nodeOps[phi].push(st[st.length - 1]); + } + } + return pushed; + }; + + const frames: { b: number; ci: number; pushed: number[] }[] = [ + { b: S, ci: 0, pushed: enterBlock(S) }, + ]; + while (frames.length) { + const f = frames[frames.length - 1]; + const kids = domChildren[f.b]; + if (f.ci < kids.length) { + const c = kids[f.ci++]; + frames.push({ b: c, ci: 0, pushed: enterBlock(c) }); + } else { + for (const v of f.pushed) stacks[v].pop(); + frames.pop(); + } + } + + // ── reaching sets per node via SCC condensation (cycle-safe union) ── + // Tarjan condenses the value graph (operand cycles from loop φs collapse to a + // single SCC); a forward pass over the reverse-topo SCC order unions each + // SCC's reaching set from its operands' (alias fast path for single-source + // SCCs — #2201 review R2). Both stages are pure (reaching-defs-graph.ts). + const { sccOf, sccMembers } = tarjanScc(nodeOps); + const reachByScc = condenseReachingSets(sccMembers, sccOf, nodeKeys, nodeOps); + + return { + converged: true, + reachingAt: (blockIndex, binding) => { + const node = entryValue[blockIndex]?.get(binding); + if (node === undefined) return undefined; + const set = reachByScc[sccOf[node]]; + return set.size ? set : undefined; + }, + }; +} + +/** + * Minimum block count below which SSA construction (dominators + dominance + * frontiers + φ-placement + renaming + SCC) does not amortize over the dense + * worklist's single-pass aliasing. Calibrated empirically (~14-block crossover + * for loop-heavy functions; 16 leaves headroom); the dense-bindings + * `rd_scaling_budget` gate in bench/cfg/baselines.json catches a regression if + * this is mistuned. Paired with a reachable-loop check — loop-free functions + * always take the cheaper dense path regardless of size. + */ +const SSA_MIN_BLOCKS = 16; + +/** + * Default ceiling on the SSA-sparse solver's value-graph node count (#2201 + * review R1). Above this the sparse path falls back to the dense oracle (which + * bounds its own work via `maxBlockVisits`), trading the deep-loop full-facts + * win for bounded memory on pathological inputs. Sized FAR above any real or + * benchmarked function: the suite's densest SSA scenarios (`dense-bindings`, + * `deep-nest`) build well under 10⁴ nodes, while the pathology this guards + * (thousands of blocks × hundreds of bindings) builds 10⁶–10⁷. The + * `dense-bindings` / `deep-nest` `rd_scaling_budget` gates in + * bench/cfg/baselines.json fail if this is set so low it forces those scenarios + * onto the dense path. Overridable per-call via + * {@link ReachingDefsLimits.maxSsaValueGraphNodes}. + */ +const DEFAULT_MAX_SSA_VALUE_GRAPH_NODES = 1_000_000; + +/** + * Production solver dispatcher (#2201). The SSA solver beats the dense worklist + * only when there is enough work to amortize SSA construction — a loop (so the + * dense fixpoint pays the loop-depth pass multiplier, or truncates at the + * ceiling) AND a non-trivial block count. Small or loop-free functions, which + * dense solves in one or two cheap aliasing passes, stay on the dense path. + * Because the two solvers are byte-identical (held by the equivalence fuzz), + * this is a pure performance heuristic with no effect on results. + * + * @internal + */ +function computeInSetsAuto( + cfg: FunctionCfg, + n: number, + h: Harvest, + adj: Adjacency, + limits: ReachingDefsLimits | undefined, +): InSetsResult { + if (n >= SSA_MIN_BLOCKS && hasReachableLoop(cfg.entryIndex, adj.succs, n)) { + return computeInSetsSparse(cfg, n, h, adj, limits); + } + return computeInSetsDense(cfg, n, h, adj, limits); +} + +/** + * Statement sweep — recover statement-granular def→use facts from the per-block + * entry reaching lattices, sort them, and apply the maxFacts truncation. SHARED + * by both solvers, and the maxFacts cutoff is where their (intentionally + * different) reaching-set INSERTION orders would otherwise leak into the output: + * the dense worklist seeds keys in RPO fixpoint order, the SSA solver in + * renaming/SCC order, so a loop-carried use's reaching set is the same SET in a + * different order. The byte-identity of a TRUNCATED result therefore does NOT + * come from matching insertion orders — it comes from the KTD6 per-use + * `useKeys.sort()` BELOW, which canonicalizes each use's keys by defKey before + * the cutoff. (The full, untruncated fact array is re-sorted at the end, so the + * pre-sort is a no-op there; its whole purpose is the truncated prefix.) Outer + * emission order — block index, then statement index, then use order — is shared + * structurally and needs no canonicalization. + */ +function sweepFacts( + blocks: FunctionCfg['blocks'], + reachingAt: ReachingAt, + defLine: ReadonlyMap, + maxFacts: number, +): { facts: DefUseFact[]; truncated: boolean } { const facts: DefUseFact[] = []; let truncated = false; + // Scratch buffer for one use's reaching def-keys, reused across every use to + // avoid a per-use array allocation (#2201 review R9). Cleared per use; the + // KTD6 sort below operates on it in place. + const useKeys: number[] = []; outer: for (const b of blocks) { const stmts = b.statements; if (!stmts || stmts.length === 0) continue; - // Lazy overlay of IN — entries are replaced (never mutated) on def, so the - // shared sets stay intact. - let reach: Lattice | null = null; + // Sparse intra-block overlay: only the bindings REDEFINED within this block + // so far. A use's reaching set is the overlay's override if present, else + // the block-entry reaching set (reachingAt). This never materializes the + // full block lattice — the dense O(live-vars) per-block copy the sparse + // solver exists to avoid. + const overlay = new Map(); for (let i = 0; i < stmts.length; i++) { const s = stmts[i]; // A use's binding that the SAME statement also defines could be a @@ -330,17 +871,32 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit // self-fact on compound assignments is harmless; missing the // assign-and-test def→use (the most common JS idiom) would be a taint // false negative. May-defs join the self-key set the same way. - const sameStmtDefs = - s.defs.length > 0 || s.mayDefs?.length ? new Set([...s.defs, ...(s.mayDefs ?? [])]) : null; + // def/mayDef arrays are tiny (1–3 entries), so a membership scan over them + // is cheaper than the old per-statement `new Set([...defs, ...mayDefs])` + // (#2201 review R9). `hasSelfDefs` short-circuits pure-use statements. + const hasSelfDefs = s.defs.length > 0 || (s.mayDefs?.length ?? 0) > 0; for (const u of s.uses) { - const reaching = (reach ?? inSets[b.index]).get(u); - const selfKey = sameStmtDefs?.has(u) ? defKey(b.index, i) : undefined; + const reaching = overlay.get(u) ?? reachingAt(b.index, u); + const selfKey = + hasSelfDefs && (s.defs.includes(u) || (s.mayDefs?.includes(u) ?? false)) + ? defKey(b.index, i) + : undefined; if (!reaching && selfKey === undefined) continue; - const keys = - selfKey !== undefined && !reaching?.has(selfKey) - ? [...(reaching ?? []), selfKey] - : [...(reaching ?? [])]; - for (const key of keys) { + // Reuse the scratch buffer instead of spreading a fresh array per use. + useKeys.length = 0; + if (reaching) for (const k of reaching) useKeys.push(k); + if (selfKey !== undefined && !reaching?.has(selfKey)) useKeys.push(selfKey); + // Canonical emission order (#2201 KTD6): sort each use's reaching + // def-sites by defKey (= def block, then def stmt) BEFORE the maxFacts + // cutoff. The full (untruncated) fact array is re-sorted identically at + // the end, so this is a no-op there; its purpose is to make the + // TRUNCATED subset schedule-independent — the reaching SET's insertion + // order is fixpoint-evaluation-order-dependent for loop-carried + // bindings (dense RPO vs sparse change-driven seed different keys + // first), so a pre-sort cutoff is what keeps the two solvers' + // truncated results byte-identical. + useKeys.sort((a, b) => a - b); + for (const key of useKeys) { if (facts.length >= maxFacts) { truncated = true; break outer; @@ -356,16 +912,14 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit } if (s.mayDefs?.length) { // Gen WITHOUT kill: the conditional def joins the binding's set. - if (!reach) reach = new Map(inSets[b.index]); const key = defKey(b.index, i); for (const d of s.mayDefs) { - const prior = reach.get(d); - reach.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key])); + const prior = overlay.get(d) ?? reachingAt(b.index, d); + overlay.set(d, prior ? unionSets(prior, new Set([key])) : new Set([key])); } } if (s.defs.length > 0) { - if (!reach) reach = new Map(inSets[b.index]); - for (const d of s.defs) reach.set(d, new Set([defKey(b.index, i)])); // kill + gen + for (const d of s.defs) overlay.set(d, new Set([defKey(b.index, i)])); // kill + gen } } } @@ -379,41 +933,7 @@ export function computeReachingDefs(cfg: FunctionCfg, limits?: ReachingDefsLimit a.bindingIdx - b.bindingIdx, ); - return { - status: truncated ? 'truncated' : 'computed', - bindings: cfg.bindings, - facts, - defCount, - useCount, - }; -} - -/** RPO over blocks reachable from `entry`; unreachable blocks appended by index. */ -function reversePostOrder(entry: number, succs: readonly number[][], n: number): number[] { - const visited = new Array(n).fill(false); - const post: number[] = []; - // Iterative DFS with an explicit phase stack (children pushed in reverse so - // they pop in sorted order — determinism). - const stack: { node: number; childIdx: number }[] = [{ node: entry, childIdx: 0 }]; - visited[entry] = true; - while (stack.length) { - const top = stack[stack.length - 1]; - const children = succs[top.node]; - if (top.childIdx < children.length) { - const next = children[top.childIdx]; - top.childIdx += 1; - if (!visited[next]) { - visited[next] = true; - stack.push({ node: next, childIdx: 0 }); - } - } else { - post.push(top.node); - stack.pop(); - } - } - const order = post.reverse(); - for (let b = 0; b < n; b++) if (!visited[b]) order.push(b); - return order; + return { facts, truncated }; } /** @@ -465,32 +985,3 @@ function mergePreds( } return merged; } - -/** Order-stable union of two def-sets (shares `a` when `b` adds nothing). */ -function unionSets(a: DefSet, b: DefSet): DefSet { - let target = a; - let copied = false; - for (const key of b) { - if (!target.has(key)) { - if (!copied) { - target = new Set(a); - copied = true; - } - target.add(key); - } - } - return target; -} - -/** Per-binding equality with a reference fast path (sets only ever grow). */ -function latticeEquals(a: Lattice, b: Lattice): boolean { - if (a === b) return true; - if (a.size !== b.size) return false; - for (const [k, bSet] of b) { - const aSet = a.get(k); - if (aSet === bSet) continue; - if (!aSet || aSet.size !== bSet.size) return false; - for (const v of bSet) if (!aSet.has(v)) return false; - } - return true; -} diff --git a/gitnexus/src/core/run-analyze.ts b/gitnexus/src/core/run-analyze.ts index 7910dde60..769125ad5 100644 --- a/gitnexus/src/core/run-analyze.ts +++ b/gitnexus/src/core/run-analyze.ts @@ -415,6 +415,14 @@ export const resolvePdgConfig = (options: PdgOptions): RepoMeta['pdg'] => // outlive the model that produced them — ANY model-content change // ships as a new digest and repopulates the taint edges. taintModelVersion, + // #2201 review R3: reaching-defs solver identity. The SSA-sparse rewrite + // computes full facts for deep-loop functions the dense worklist used to + // truncate to empty, so an existing `--pdg` index carries stale-truncated + // REACHING_DEF rows. Absent on any pre-#2201 stamp → the key-union + // pdgModeMismatch trips on the first upgraded run and forces the full + // writeback that recomputes the fuller coverage (no `--force` needed). + // Bump this tag on any future change to which facts the solver emits. + reachingDefSolver: 'ssa-sparse-v1', } : undefined; diff --git a/gitnexus/src/storage/repo-manager.ts b/gitnexus/src/storage/repo-manager.ts index b03db9fc5..9e39a6cc0 100644 --- a/gitnexus/src/storage/repo-manager.ts +++ b/gitnexus/src/storage/repo-manager.ts @@ -194,6 +194,19 @@ export interface RepoMeta { * without `--force`. Optional: absent on pre-M3 stamps. */ taintModelVersion?: string; + /** + * Identity of the reaching-definitions solver the persisted REACHING_DEF + * rows were produced under (#2201 review R3). The SSA-sparse rewrite computes + * FULL facts for deep-loop functions the old dense worklist truncated to + * empty (the blocks×64 ceiling no longer fires) — but an existing `--pdg` + * index built under the old solver carries those truncated rows. ABSENT on + * any pre-#2201 stamp, so that absence trips `pdgModeMismatch` on the first + * upgraded run and forces the full writeback that recomputes the now-fuller + * REACHING_DEF coverage without `--force`. Bump the tag on any future change + * that alters which facts the solver emits. Optional for that upgrade reason; + * resolved (always present) on every post-#2201 write. + */ + reachingDefSolver?: string; }; } diff --git a/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts b/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts new file mode 100644 index 000000000..e56077cba --- /dev/null +++ b/gitnexus/test/unit/cfg/reaching-defs-equivalence.test.ts @@ -0,0 +1,613 @@ +/** + * #2201 — differential equivalence harness for the reaching-defs solvers. + * + * The SSA-sparse rewrite must be BYTE-IDENTICAL to the retained dense GEN/KILL + * oracle ({@link computeReachingDefsDense}). This file is the permanent gate: + * a seeded random-CFG generator drives both solvers and a structural comparator + * asserts identical status / bindings / sorted facts / def-use telemetry. + * + * In U1 both sides run the dense oracle (self-equivalence + corpus-coverage + * sanity); U5 flips the second solver to {@link computeReachingDefs} (sparse) + * — the single change that turns this into the real equivalence gate. + * + * The corpus deliberately covers the shapes where a may-reaching-defs rewrite + * is most likely to diverge: loops + irreducible (goto) topology, throw edges + * (IN∪allDefs handler semantics), may-defs (gen-without-kill), shadowed + * bindings, unreachable blocks, multi-predecessor joins, and the maxFacts / + * maxBlockVisits truncation postures (KTD6 — the truncated SUBSET depends on + * pre-sort emission order, so it must match too). + * + * Default corpus is CI-fast; GITNEXUS_RD_FUZZ_N raises it (the ≥1M run the + * plan calls for) for a deep local/CI-shard pass. + */ +import { describe, it, expect } from 'vitest'; +import { + computeReachingDefs, + computeReachingDefsDense, + computeReachingDefsSparse, + type FunctionDefUse, + type ReachingDefsLimits, +} from '../../../src/core/ingestion/cfg/reaching-defs.js'; +import type { + BindingEntry, + BasicBlockData, + CfgEdgeData, + CfgEdgeKind, + FunctionCfg, + StatementFacts, +} from '../../../src/core/ingestion/cfg/types.js'; + +type Solver = (cfg: FunctionCfg, limits?: ReachingDefsLimits) => FunctionDefUse; + +// ── deterministic PRNG (mulberry32) ─────────────────────────────────────── +function mulberry32(seed: number): () => number { + let a = seed >>> 0; + return () => { + a |= 0; + a = (a + 0x6d2b79f5) | 0; + let t = Math.imul(a ^ (a >>> 15), 1 | a); + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t; + return ((t ^ (t >>> 14)) >>> 0) / 4294967296; + }; +} + +const NON_THROW_KINDS: CfgEdgeKind[] = [ + 'seq', + 'cond-true', + 'cond-false', + 'loop-back', + 'break', + 'continue', + 'return', + 'switch-case', + 'fallthrough', +]; + +// ── random CFG generator ─────────────────────────────────────────────────── +interface GenOpts { + maxBlocks: number; + maxBindings: number; + maxStmtsPerBlock: number; + pNoBindings: number; // chance the whole CFG has bindings:undefined (→ no-facts) + pThrowEdge: number; + pMayDef: number; + pExtraEdge: number; // per-block chance of an extra random edge + pShadowName: number; +} + +const DEFAULT_GEN: GenOpts = { + // Span both sides of the production SSA dispatch threshold (SSA_MIN_BLOCKS=16): + // CFGs below it route the auto-dispatcher (computeReachingDefs) to dense, those + // above with a reachable loop route it to the SSA path — so the corpus + // differentially exercises BOTH branches of computeInSetsAuto, not just the + // forced-SSA computeReachingDefsSparse entry. See the hadLargeLoop coverage + // guard below. + maxBlocks: 36, + maxBindings: 8, + maxStmtsPerBlock: 4, + pNoBindings: 0.03, + pThrowEdge: 0.12, + pMayDef: 0.18, + pExtraEdge: 0.9, + pShadowName: 0.4, +}; + +function genCfg(seed: number, opts: GenOpts = DEFAULT_GEN): FunctionCfg { + const rnd = mulberry32(seed); + const int = (n: number) => Math.floor(rnd() * n); + const n = 1 + int(opts.maxBlocks); // ≥1 block (entry) + + // bindings — small name pool so shadowing collisions happen; distinct + // declLine/declColumn keep non-synthetic bindings' keys distinct. + const noBindings = rnd() < opts.pNoBindings; + const nBindings = noBindings ? 0 : int(opts.maxBindings + 1); + const namePool = ['a', 'b', 'c', 'd', 'e']; + const kinds: BindingEntry['kind'][] = ['var', 'let', 'const', 'param', 'catch']; + const bindings: BindingEntry[] = []; + for (let i = 0; i < nBindings; i++) { + const shadow = rnd() < opts.pShadowName; + bindings.push({ + name: shadow ? namePool[int(namePool.length)] : `v${i}`, + declLine: 100 + i, + declColumn: i, + kind: kinds[int(kinds.length)], + ...(rnd() < 0.08 ? { synthetic: true } : {}), + }); + } + + const pickBindings = (max: number): number[] => { + if (nBindings === 0) return []; + const out: number[] = []; + const count = int(max + 1); + for (let k = 0; k < count; k++) out.push(int(nBindings)); + return out; + }; + + // blocks (block 0 = entry; some blocks get no statements like synthetic + // ENTRY/EXIT to exercise the skip paths). + const blocks: BasicBlockData[] = []; + for (let b = 0; b < n; b++) { + const stmtCount = b === 0 && rnd() < 0.5 ? int(2) : int(opts.maxStmtsPerBlock + 1); + const statements: StatementFacts[] = []; + for (let i = 0; i < stmtCount; i++) { + const defs = pickBindings(2); + const uses = pickBindings(3); + const mayDefs = rnd() < opts.pMayDef ? pickBindings(1) : []; + statements.push({ + line: b * 100 + i + 1, + defs, + uses, + ...(mayDefs.length ? { mayDefs } : {}), + }); + } + blocks.push({ + index: b, + startLine: b * 100, + endLine: b * 100 + stmtCount, + text: `B${b}`, + kind: b === 0 ? 'entry' : b === n - 1 ? 'exit' : 'normal', + // bindings:undefined ⇒ no-facts: drop statements entirely so it mirrors a + // pre-M2 CFG (the solver keys no-facts off cfg.bindings, but a realistic + // no-facts CFG also lacks statements). + ...(noBindings ? {} : { statements }), + }); + } + + // edges — a probabilistic spine (entry chain) for reachability + random + // extra edges that produce loops, irreducible topology, and unreachable + // blocks. Throw edges target a random handler block. + const edges: CfgEdgeData[] = []; + const addEdge = (from: number, to: number, kind: CfgEdgeKind) => { + if (from >= 0 && from < n && to >= 0 && to < n) edges.push({ from, to, kind }); + }; + for (let b = 0; b < n - 1; b++) { + if (rnd() < 0.75) addEdge(b, b + 1, 'seq'); + } + for (let b = 0; b < n; b++) { + if (rnd() < opts.pExtraEdge) { + const to = int(n); // any target → forward / back / self / cross edges + const throwIt = rnd() < opts.pThrowEdge; + addEdge(b, to, throwIt ? 'throw' : NON_THROW_KINDS[int(NON_THROW_KINDS.length)]); + } + } + + return { + filePath: 'fuzz.ts', + functionStartLine: 1, + functionEndLine: n * 100, + functionStartColumn: 0, + entryIndex: 0, + exitIndex: n - 1, + blocks, + edges, + ...(noBindings ? {} : { bindings }), + }; +} + +// ── hand-built canonical hard CFGs (guaranteed shape coverage) ───────────── +// These pin the gnarly shapes the random generator hits only probabilistically. +function canonicalHardCfgs(): FunctionCfg[] { + const mk = ( + blocks: BasicBlockData[], + edges: CfgEdgeData[], + bindings: BindingEntry[], + ): FunctionCfg => ({ + filePath: 'canon.ts', + functionStartLine: 1, + functionEndLine: 999, + functionStartColumn: 0, + entryIndex: 0, + exitIndex: blocks.length - 1, + blocks, + edges, + bindings, + }); + const bind = (name: string, line: number): BindingEntry => ({ + name, + declLine: line, + declColumn: 0, + kind: 'let', + }); + const blk = (index: number, statements: StatementFacts[]): BasicBlockData => ({ + index, + startLine: index * 10, + endLine: index * 10 + statements.length, + text: `B${index}`, + kind: index === 0 ? 'entry' : 'normal', + statements, + }); + const st = ( + line: number, + defs: number[], + uses: number[], + mayDefs?: number[], + ): StatementFacts => ({ + line, + defs, + uses, + ...(mayDefs ? { mayDefs } : {}), + }); + + const out: FunctionCfg[] = []; + + // (1) Irreducible two-entry loop: 0→1, 0→2, 1→2, 2→1. binding x def in 1, use in 2 & 1. + out.push( + mk( + [blk(0, [st(1, [0], [])]), blk(1, [st(2, [0], [0])]), blk(2, [st(3, [], [0])])], + [ + { from: 0, to: 1, kind: 'cond-true' }, + { from: 0, to: 2, kind: 'cond-false' }, + { from: 1, to: 2, kind: 'seq' }, + { from: 2, to: 1, kind: 'loop-back' }, + ], + [bind('x', 1)], + ), + ); + + // (2) Self-loop with may-def: block 1 loops to itself; x may-def + use. + out.push( + mk( + [blk(0, [st(1, [0], [])]), blk(1, [st(2, [], [0], [0])])], + [ + { from: 0, to: 1, kind: 'seq' }, + { from: 1, to: 1, kind: 'loop-back' }, + ], + [bind('x', 1)], + ), + ); + + // (3) try/catch throw edge: 0 (x=1), 1 (x=parse; x=normalize) -throw-> 2 (use x). + out.push( + mk( + [ + blk(0, [st(1, [0], [])]), + blk(1, [st(2, [0], []), st(3, [0], [0])]), + blk(2, [st(4, [], [0])]), + ], + [ + { from: 0, to: 1, kind: 'seq' }, + { from: 1, to: 2, kind: 'seq' }, + { from: 1, to: 2, kind: 'throw' }, + ], + [bind('x', 1)], + ), + ); + + // (4) Diamond merge: both arm defs reach the join use. + out.push( + mk( + [ + blk(0, [st(1, [], [])]), + blk(1, [st(2, [0], [])]), + blk(2, [st(3, [0], [])]), + blk(3, [st(4, [], [0])]), + ], + [ + { from: 0, to: 1, kind: 'cond-true' }, + { from: 0, to: 2, kind: 'cond-false' }, + { from: 1, to: 3, kind: 'seq' }, + { from: 2, to: 3, kind: 'seq' }, + ], + [bind('x', 1)], + ), + ); + + // (5) Unreachable block carrying a def (block 2 not reachable from entry). + out.push( + mk( + [blk(0, [st(1, [0], [0])]), blk(1, [st(2, [], [0])]), blk(2, [st(3, [0], [0])])], + [{ from: 0, to: 1, kind: 'seq' }], + [bind('x', 1)], + ), + ); + + // (6) Back-edge into the ENTRY block: 0 (def+use x) → 1 (def+use x) → 0 (loop + // back to entry) and 1 → 2 (exit, use x). The SSA solver's synthetic pre-entry + // node exists precisely for this — the entry is a loop header, so x's loop- + // carried def must reach the entry's own use. Pins that path deterministically. + out.push( + mk( + [blk(0, [st(1, [0], [0])]), blk(1, [st(2, [0], [0])]), blk(2, [st(3, [], [0])])], + [ + { from: 0, to: 1, kind: 'seq' }, + { from: 1, to: 0, kind: 'loop-back' }, + { from: 1, to: 2, kind: 'cond-false' }, + ], + [bind('x', 1)], + ), + ); + + // (7) Malformed input: an OUT-OF-RANGE binding index (≥ nBindings, e.g. from a + // corrupted/stale durable store) in a looping CFG. The dense solver tolerates + // it (its lattice is a Map keyed by index); the SSA path must fall back to + // dense rather than crash its nBindings-sized arrays. Asserting byte-identity + // here pins that gate — without it, the SSA path throws and the differential + // comparison can never reach this divergent input (the generator only ever + // emits in-range indices). + out.push( + mk( + [blk(0, [st(1, [0], [])]), blk(1, [st(2, [3], [3])]), blk(2, [st(3, [], [0])])], + [ + { from: 0, to: 1, kind: 'seq' }, + { from: 1, to: 1, kind: 'loop-back' }, + { from: 1, to: 2, kind: 'cond-false' }, + ], + [bind('x', 1)], // nBindings = 1, so binding index 3 in block 1 is out of range + ), + ); + + return out; +} + +// ── structural comparator ────────────────────────────────────────────────── +function serializeFact(f: FunctionDefUse['facts'][number]): string { + return ( + `${f.def.blockIndex}:${f.def.stmtIndex}@${f.def.line}` + + `->${f.use.blockIndex}:${f.use.stmtIndex}@${f.use.line}#${f.bindingIdx}` + ); +} + +/** Returns null when byte-identical, else a human-readable first divergence. */ +function diffDefUse(a: FunctionDefUse, b: FunctionDefUse): string | null { + if (a.status !== b.status) return `status: ${a.status} vs ${b.status}`; + if (a.defCount !== b.defCount) return `defCount: ${a.defCount} vs ${b.defCount}`; + if (a.useCount !== b.useCount) return `useCount: ${a.useCount} vs ${b.useCount}`; + if (a.bindings.length !== b.bindings.length) { + return `bindings.length: ${a.bindings.length} vs ${b.bindings.length}`; + } + if (a.facts.length !== b.facts.length) { + return `facts.length: ${a.facts.length} vs ${b.facts.length}`; + } + for (let i = 0; i < a.facts.length; i++) { + const fa = serializeFact(a.facts[i]); + const fb = serializeFact(b.facts[i]); + if (fa !== fb) return `fact[${i}]: ${fa} vs ${fb}`; + } + return null; +} + +// ── corpus shape classifier (coverage guard) ─────────────────────────────── +interface ShapeFlags { + hasLoop: boolean; + hasThrow: boolean; + hasMayDef: boolean; + hasShadow: boolean; + hasMultiPred: boolean; + hasUnreachable: boolean; + // ≥16-block CFG (SSA_MIN_BLOCKS) with a loop reachable from entry — the exact + // shape the production dispatcher (computeInSetsAuto) sends to the SSA solver. + // Asserting it proves the auto-dispatcher's SSA branch is differentially fuzzed. + hadLargeLoop: boolean; + hadComputed: boolean; + hadTruncated: boolean; + hadNoFacts: boolean; +} + +function classify(cfg: FunctionCfg, flags: ShapeFlags): void { + const n = cfg.blocks.length; + if (cfg.edges.some((e) => e.kind === 'throw')) flags.hasThrow = true; + if (cfg.blocks.some((b) => b.statements?.some((s) => s.mayDefs?.length))) flags.hasMayDef = true; + if (cfg.bindings) { + const names = cfg.bindings.map((b) => b.name); + if (new Set(names).size < names.length) flags.hasShadow = true; + } + const predCount = new Array(n).fill(0); + for (const e of cfg.edges) if (e.to >= 0 && e.to < n) predCount[e.to]++; + if (predCount.some((c) => c >= 2)) flags.hasMultiPred = true; + + // cycle detection (DFS rec-stack) over the whole graph + const succ: number[][] = Array.from({ length: n }, () => []); + for (const e of cfg.edges) + if (e.from >= 0 && e.from < n && e.to >= 0 && e.to < n) succ[e.from].push(e.to); + const color = new Array(n).fill(0); // 0=white 1=gray 2=black + const hasCycleFrom = (start: number): boolean => { + const stack: { node: number; idx: number }[] = [{ node: start, idx: 0 }]; + color[start] = 1; + while (stack.length) { + const top = stack[stack.length - 1]; + if (top.idx < succ[top.node].length) { + const nx = succ[top.node][top.idx++]; + if (color[nx] === 1) return true; + if (color[nx] === 0) { + color[nx] = 1; + stack.push({ node: nx, idx: 0 }); + } + } else { + color[top.node] = 2; + stack.pop(); + } + } + return false; + }; + for (let s = 0; s < n; s++) if (color[s] === 0 && hasCycleFrom(s)) flags.hasLoop = true; + + // reachability from entry + const seen = new Array(n).fill(false); + const q = [cfg.entryIndex]; + seen[cfg.entryIndex] = true; + while (q.length) { + const x = q.pop()!; + for (const y of succ[x]) if (!seen[y]) ((seen[y] = true), q.push(y)); + } + if (seen.some((v, i) => !v && i < n)) flags.hasUnreachable = true; + + // loop reachable from entry (matches the dispatcher's hasReachableLoop) + + // ≥16 blocks ⇒ the production auto-dispatcher routes this CFG to the SSA path. + const c2 = new Array(n).fill(0); + let entryLoop = false; + const st2: { node: number; idx: number }[] = [{ node: cfg.entryIndex, idx: 0 }]; + c2[cfg.entryIndex] = 1; + while (st2.length && !entryLoop) { + const top = st2[st2.length - 1]; + if (top.idx < succ[top.node].length) { + const v = succ[top.node][top.idx++]; + if (c2[v] === 1) entryLoop = true; + else if (c2[v] === 0) ((c2[v] = 1), st2.push({ node: v, idx: 0 })); + } else ((c2[top.node] = 2), st2.pop()); + } + if (n >= 16 && entryLoop) flags.hadLargeLoop = true; +} + +// ── corpus runner ────────────────────────────────────────────────────────── +interface CorpusResult { + checked: number; + flags: ShapeFlags; + firstFailure: string | null; +} + +function runCorpus( + left: Solver, + right: Solver, + count: number, + baseSeed: number, + // maxBlockVisits has DIFFERENT (intentional) semantics across the solvers: the + // dense worklist counts block dequeues against it; the SSA solver has no + // fixpoint iteration and ignores it in its main path (it only flows through to + // the dense fallback for throw-edge/unreachable functions). So a tight budget + // truncates them at different points. Perturb it only when comparing a solver + // against ITSELF (same semantics); cross-solver byte-identity is asserted with + // the budget unlimited (both fully converge). + perturbBlockVisits = true, +): CorpusResult { + const flags: ShapeFlags = { + hasLoop: false, + hasThrow: false, + hasMayDef: false, + hasShadow: false, + hadLargeLoop: false, + hasMultiPred: false, + hasUnreachable: false, + hadComputed: false, + hadTruncated: false, + hadNoFacts: false, + }; + let firstFailure: string | null = null; + let checked = 0; + + const check = (cfg: FunctionCfg, limits: ReachingDefsLimits | undefined, label: string): void => { + const a = left(cfg, limits); + const b = right(cfg, limits); + const d = diffDefUse(a, b); + checked++; + if (a.status === 'computed') flags.hadComputed = true; + if (a.status === 'truncated') flags.hadTruncated = true; + if (a.status === 'no-facts') flags.hadNoFacts = true; + if (d && !firstFailure) firstFailure = `${label}: ${d}`; + }; + + // canonical hard CFGs first (under several limit postures) + for (const [i, cfg] of canonicalHardCfgs().entries()) { + classify(cfg, flags); + check(cfg, undefined, `canon[${i}]`); + check(cfg, { maxFacts: 1 }, `canon[${i}]/maxFacts=1`); + check(cfg, { maxFacts: 2 }, `canon[${i}]/maxFacts=2`); + if (perturbBlockVisits) check(cfg, { maxBlockVisits: 2 }, `canon[${i}]/maxBlockVisits=2`); + } + + // random corpus + for (let i = 0; i < count; i++) { + const seed = baseSeed + i; + const cfg = genCfg(seed); + classify(cfg, flags); + check(cfg, undefined, `seed=${seed}`); + // exercise truncation on ~1/4 of cases (small maxFacts) and the block-visit + // ceiling on ~1/8 — both must match byte-for-byte (KTD6). + if (i % 4 === 0) check(cfg, { maxFacts: 1 + (i % 3) }, `seed=${seed}/maxFacts`); + if (perturbBlockVisits && i % 8 === 0) { + check(cfg, { maxBlockVisits: 1 + (i % 4) }, `seed=${seed}/maxBlockVisits`); + } + } + + return { checked, flags, firstFailure }; +} + +const CORPUS_N = Number(process.env.GITNEXUS_RD_FUZZ_N ?? 1500); + +describe('#2201 reaching-defs differential equivalence', () => { + it('dense oracle is self-consistent and the comparator + generator are sound', () => { + // U1 baseline: dense-vs-dense MUST be byte-identical (proves the harness). + const r = runCorpus(computeReachingDefsDense, computeReachingDefsDense, CORPUS_N, 0x2201); + expect(r.firstFailure).toBeNull(); + expect(r.checked).toBeGreaterThan(CORPUS_N); + }); + + it('the corpus exercises every divergence-prone shape (coverage guard)', () => { + const r = runCorpus(computeReachingDefsDense, computeReachingDefsDense, CORPUS_N, 0x2201); + const f = r.flags; + expect(f.hasLoop, 'loops').toBe(true); + expect(f.hasThrow, 'throw edges').toBe(true); + expect(f.hasMayDef, 'may-defs').toBe(true); + expect(f.hasShadow, 'shadowed bindings').toBe(true); + expect(f.hasMultiPred, 'multi-pred joins').toBe(true); + expect(f.hasUnreachable, 'unreachable blocks').toBe(true); + expect(f.hadLargeLoop, '≥16-block looping CFGs (production SSA dispatch path)').toBe(true); + expect(f.hadComputed, 'computed results').toBe(true); + expect(f.hadTruncated, 'truncated results').toBe(true); + expect(f.hadNoFacts, 'no-facts results').toBe(true); + }); + + it('is deterministic — a fixed seed yields a byte-identical corpus across runs', () => { + const a = runCorpus(computeReachingDefsDense, computeReachingDefsDense, 200, 0xfeed); + const b = runCorpus(computeReachingDefsDense, computeReachingDefsDense, 200, 0xfeed); + expect(a.checked).toBe(b.checked); + expect(a.flags).toEqual(b.flags); + }); + + it('the SPARSE solver is byte-identical to the dense oracle (#2201 gate)', () => { + // The load-bearing equivalence gate: sparse vs dense across the full corpus, + // budget unlimited so both fully converge. maxFacts truncation IS compared + // (it must match byte-for-byte — KTD6); maxBlockVisits is not (the two count + // different things on purpose — that contrast is the no-regression test). + const r = runCorpus( + computeReachingDefsSparse, + computeReachingDefsDense, + CORPUS_N, + 0x2201, + /* perturbBlockVisits */ false, + ); + expect(r.firstFailure).toBeNull(); + expect(r.flags.hadComputed && r.flags.hadTruncated).toBe(true); + }); + + it('PRODUCTION computeReachingDefs is byte-identical to the dense oracle', () => { + // U1: computeReachingDefs delegates to dense (trivially green). U5 swaps it + // to the sparse solver — this stays the production-entry gate. + const r = runCorpus( + computeReachingDefs, + computeReachingDefsDense, + CORPUS_N, + 0x5eed, + /* perturbBlockVisits */ false, + ); + expect(r.firstFailure).toBeNull(); + }); + + it('sparse never regresses coverage under the production block-visit budget', () => { + // Production posture: emit passes maxBlockVisits = blocks × 64. The contract + // is one-directional — wherever the dense solver COMPUTES, the sparse solver + // must also compute and produce identical facts (no lost REACHING_DEF + // coverage). The reverse is allowed and desired: sparse may compute deep + // nests the dense solver truncates (the #2201 ceiling-stops-firing win). + let regressions = 0; + let firstRegression: string | null = null; + for (let i = 0; i < CORPUS_N; i++) { + const cfg = genCfg(0xc0de + i); + const budget = { maxBlockVisits: cfg.blocks.length * 64 }; + const dense = computeReachingDefsDense(cfg, budget); + const sparse = computeReachingDefsSparse(cfg, budget); + if (dense.status === 'computed') { + const d = diffDefUse(dense, sparse); + if (d) { + regressions++; + if (!firstRegression) firstRegression = `seed=${0xc0de + i}: ${d}`; + } + } + } + expect(firstRegression).toBeNull(); + expect(regressions).toBe(0); + }); +}); + +// Re-exported for U5 and future harness reuse. +export { genCfg, canonicalHardCfgs, diffDefUse, runCorpus, classify }; +export type { Solver, ShapeFlags, CorpusResult }; diff --git a/gitnexus/test/unit/cfg/reaching-defs.test.ts b/gitnexus/test/unit/cfg/reaching-defs.test.ts index 244bc8f58..9fd8c39f0 100644 --- a/gitnexus/test/unit/cfg/reaching-defs.test.ts +++ b/gitnexus/test/unit/cfg/reaching-defs.test.ts @@ -8,6 +8,8 @@ import { } from '../../../src/core/ingestion/cfg/visitors/typescript.js'; import { computeReachingDefs, + computeReachingDefsDense, + computeReachingDefsSparse, type DefUseFact, } from '../../../src/core/ingestion/cfg/reaching-defs.js'; import type { @@ -371,6 +373,115 @@ describe('computeReachingDefs — determinism and convergence', () => { 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 | 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)', () => { diff --git a/gitnexus/test/unit/pdg-mode-flip.test.ts b/gitnexus/test/unit/pdg-mode-flip.test.ts index 5e8dbe2f9..585747eb7 100644 --- a/gitnexus/test/unit/pdg-mode-flip.test.ts +++ b/gitnexus/test/unit/pdg-mode-flip.test.ts @@ -176,6 +176,42 @@ describe('pdgModeMismatch — pre-M5→M5 CDG-cap stamp upgrade (#2085 M5, pure) }); }); +describe('pdgModeMismatch — pre-#2201→SSA reaching-defs solver upgrade (#2201 review R3, pure)', () => { + it('resolvePdgConfig stamps the reaching-defs solver identity', async () => { + const { resolvePdgConfig } = await import('../../src/core/run-analyze.js'); + const stamp = resolvePdgConfig({ pdg: true }); + expect(stamp?.reachingDefSolver).toBe('ssa-sparse-v1'); + }); + + it('a pre-#2201 stamp (no solver key) mismatches the SSA request — upgrade recomputes truncated deep-loop facts', async () => { + const { pdgModeMismatch } = await import('../../src/core/run-analyze.js'); + // What a pre-#2201 (M5-era) run wrote: every cap + model digest, but NO + // reachingDefSolver. The key-union comparator sees 'ssa-sparse-v1' !== + // undefined and trips the full writeback that recomputes the now-fuller + // REACHING_DEF coverage — the deep-loop functions the dense worklist + // truncated to empty at the blocks×64 ceiling now compute full facts. + const m5Stamp = { + maxFunctionLines: 2000, + maxEdgesPerFunction: 5000, + maxReachingDefEdgesPerFunction: 4000, + maxCdgEdgesPerFunction: 5000, + maxTaintFindingsPerFunction: 200, + maxTaintHops: 32, + maxInterprocFindings: 2000, + maxInterprocHops: 32, + maxInterprocEdges: 1000, + taintModelVersion, + }; + expect(pdgModeMismatch(m5Stamp, { pdg: true })).toBe(true); + }); + + it('an identical post-#2201 stamp compares equal (no spurious re-analysis churn)', async () => { + const { pdgModeMismatch, resolvePdgConfig } = await import('../../src/core/run-analyze.js'); + const stamp = resolvePdgConfig({ pdg: true }); + expect(pdgModeMismatch(stamp, { pdg: true })).toBe(false); + }); +}); + describe('detect_changes BasicBlock exclusion (#2082 U7)', () => { it('the symbol-overlap id-prefix filter excludes exactly the BasicBlock rows', async () => { const repo = await setupMiniRepo(); @@ -258,6 +294,7 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => { maxInterprocHops: 32, maxInterprocEdges: 1000, taintModelVersion, + reachingDefSolver: 'ssa-sparse-v1', }); expect(stamped!.incrementalInProgress).toBeUndefined(); // cleared on success @@ -314,6 +351,7 @@ describe('runFullAnalysis — pdg-mode flip (#2099 F1)', () => { maxInterprocHops: 32, maxInterprocEdges: 1000, taintModelVersion, + reachingDefSolver: 'ssa-sparse-v1', }); // The CFG layer survives a rebuild under a tighter edge cap (blocks are // never capped, only edges). diff --git a/gitnexus/test/unit/run-analyze.test.ts b/gitnexus/test/unit/run-analyze.test.ts index d89114360..b317d6824 100644 --- a/gitnexus/test/unit/run-analyze.test.ts +++ b/gitnexus/test/unit/run-analyze.test.ts @@ -349,6 +349,10 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => { // Content digest, not a tunable cap — pinned via the exported constant // (its VALUE changes whenever the built-in model changes, by design). taintModelVersion, + // Solver identity, not a tunable cap — always stamped on a pdg-on run + // (#2201 review R3). Bumps when the reaching-defs solver's emitted facts + // change; absence on a pre-#2201 stamp forces a re-analysis. + reachingDefSolver: 'ssa-sparse-v1', }; it('resolvePdgConfig: pdg-off run resolves to undefined (the meta field is omitted)', async () => { @@ -384,6 +388,7 @@ describe('pdgModeMismatch / resolvePdgConfig (#2099 F1)', () => { maxInterprocHops: 0, maxInterprocEdges: 0, taintModelVersion, // not a cap — always stamped on a pdg-on run + reachingDefSolver: 'ssa-sparse-v1', // solver identity — always stamped (#2201 R3) }); }); diff --git a/gitnexus/tsconfig.json b/gitnexus/tsconfig.json index 6b82c6d7d..9a3fe9ccd 100644 --- a/gitnexus/tsconfig.json +++ b/gitnexus/tsconfig.json @@ -12,6 +12,7 @@ "resolveJsonModule": true, "forceConsistentCasingInFileNames": true, "declaration": true, + "stripInternal": true, "types": ["node"] }, "include": ["src/**/*"]