mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-02 02:11:29 +00:00
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* 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) <noreply@anthropic.com>
* feat(cfg): Vue (reuse TS visitor) + worker-mode proof for all langs (#2195 U15)
Vue SFC <script> blocks are extracted and parsed with the TS grammar
(parse-worker languageMap[Vue] = TypeScript.typescript), so wire
vueProvider.cfgVisitor = createTypeScriptCfgVisitor() -- pure reuse, no
Vue-specific visitor. vue-visitor.test.ts replicates the worker path
(extractVueScript -> TS parse -> CFG) and confirms branch edges + EXIT
reverse-reachable + CDG>0.
Extend pipeline-pdg.test.ts with a worker-mode block covering all eight
remaining languages (Python/PHP/Ruby/Rust/Swift/Kotlin/Dart/Vue): per-
language temp repo, real worker pool, BasicBlock+CFG+REACHING_DEF+CDG all
> 0 with --pdg (CDG>0 proves EXIT reverse-reachable end-to-end through the
worker despite each fixture's non-terminating loop), == 0 without. Counts
e.g. Ruby 122 BB/45 CDG, Vue 49 BB/11 CDG. 30 pipeline tests green.
COBOL: documented as the deliberate PDG non-goal (no grammar, exotic
PERFORM/GO-TO control flow) in cobol.ts + the worker-roundtrip gate.
This completes PDG language coverage: every supported language except
COBOL now builds CFG/REACHING_DEF/CDG under --pdg.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): surface skippedUnsoundFunctions in per-language stats (#2195 U2)
emitFileCdg computes skippedUnsoundFunctions (functions whose CDG is
withheld because EXIT isn't reverse-reachable from all blocks) but run.ts
dropped it on the floor — only cdgEdges/cdgDropped were aggregated. Add
the aggregation + a stats-line segment so CDG coverage gaps are an
explicit signal, not silent. Establishes the baseline skip count that
makes the U1 synthetic-escape pass's effect (the drop to genuine
anomalies only) measurable.
Additive; no emit-logic change. The emit-side field is covered by
cfg-emit.test.ts (asserts skippedUnsoundFunctions===1 + the warn on a
disconnected-block CFG); the run.ts aggregation is a thin pass-through.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): synthetic-escape pass restores CDG for exit-unreachable cycles (#2195 U1)
Unconditional goto-cycles (C/C++/C#/Go) wire a backward seq edge with no
structural exit-escape edge, so EXIT becomes non-reverse-reachable and
emitFileCdg silently skipped ALL control-dependence for the function.
New cfg/synthetic-escape.ts: a pure deterministic SCC routine (iterative
Tarjan, sorted adjacency) + augmentForPostDom(cfg). No-op when EXIT is
already reverse-reachable (terminating fns + visitor-escaped loops are
byte-identical — returns the same object). Otherwise it batch-bridges
every exit-less SCC by adding an ANALYSIS-ONLY escape edge from the SCC's
controlling block (highest out-degree branch; lowest-index tie-break) to
EXIT, on a shallow-cloned FunctionCfg — never mutating persisted
cfg.edges. emitFileCdg threads that augmented view through BOTH
isExitReachableFromAllBlocks AND computeControlDependence (the Ferrante
walk re-reads cfg.edges, so a tree-only augmentation would be wrong).
Precision (anti-masking): only a trapped region containing a control
point (>=2-successor block) is bridged — a branch-less trapped region
carries no recoverable control-dependence and is indistinguishable from a
genuine construction anomaly, so it stays on the skip path (the existing
disconnected-block skip test still skips, skippedUnsoundFunctions===1). A
residual non-cycle dangling block is never bridged.
repro `void handler(int a){ start: if(a>0){work();} goto start; }`:
before exitReachable=false/CDG=0 → after one synthetic 2->1 edge,
exitReachable=true, exact CDG = {2->2:T,2->2:F,2->3:T,2->4:T,2->4:F}
(pinned exactly, not CDG>0 — catches a wrong representative). AC2 property
test extended to the augmented graph; per-language goto-cycle regressions
(C/C++/C#/Go). 199 cfg tests green; bench --check fingerprints unchanged
(analysis-only, zero persisted drift).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cfg): isolate the non-terminating-loop hazard in worker CDG asserts (#2195 U3)
The pipeline-pdg worker-mode blocks asserted a whole-fixture cdg>0
aggregate (satisfied by any branching fn) while the comment claimed it
proved the non-terminating-loop EXIT-reachability end-to-end. Add a per-
language `hazard` marker + isolate the assertion: locate the hazard
function's BasicBlocks by its anchor and assert >=1 CDG edge is sourced
within it (a marker mutation now fails the test — non-vacuous). C# keeps
the aggregate (its fixture has no infinite loop). Comments corrected.
Switch the 7 visitor unit tests (java/csharp/dart/kotlin/php/swift/c-cpp)
from the local exitReachableFromAll CFG-shape helper to the production
isExitReachableFromAllBlocks + computeControlDependence on the hazard
function, matching go/python/ruby/rust/vue. 241 unit + 30 pipeline green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cfg): gate vendored-grammar worker assertions on isLanguageAvailable (#2195 U4)
The Swift/Kotlin/Dart worker-mode pipeline-pdg cases require a vendored
grammar prebuild that may be absent on a CI platform — they'd go red
there. Mark those three REMAINING_LANGS entries `vendored` and gate both
the --pdg-on and --pdg-off `it`s on isLanguageAvailable(SupportedLanguages
[lang]) → it.skip when the grammar can't load. Installed-grammar
languages stay unconditional. Grammars present here, so all 30 run green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cfg): remove dead useCount() from swift + rust harvests (#2195 U5)
useCount() was declared on the local FactAccumulator in swift-harvest.ts
and rust-harvest.ts but never called (a copy-paste artifact; ruby's copy
IS used in an emit guard, so it stays). Pure deletion — the swift/rust
visitor suites stay green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cfg): standardize harvester API table()->bindingTable() (#2195 U9)
The binding-table accessor was named table() in the C/C++/C#/Go harvests
but bindingTable() in the other 7. Rename the 4 (definitions + their
visitor call sites) to the majority name bindingTable(). Pure rename; the
4 visitor suites stay green and tsc confirms no call site was missed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cfg): consolidate scope-tree substrate into ScopeTreeHarvester (#2195 U6)
The Go/Java/C#/C-C++ def/use harvesters each carried a byte-identical copy
of the lexical scope-tree machinery (Scope record, two-phase resolution
cache, openScope/nearestScopeOf/resolve/def/use/conditional/bindingTable,
~270 lines total). Extract it into an abstract ScopeTreeHarvester base; the
four harvesters now extend it and supply only their genuine per-language
variation (the prescan switch, plus Go's _-blank-identifier overrides of
declare/def/use). Net -422 lines. Mechanical and byte-equivalent: cfg unit
suite 613 passed, bench --check fingerprints unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cfg): consolidate no-site def/use accumulator into DefUseAccumulator (#2195 U7)
The Kotlin/Python/Ruby/Rust/Dart/Swift harvesters each carried a
byte-identical copy of the no-site def/use accumulator (~270 lines total;
only Ruby's adds the live useCount() emit-guard helper). Extract it as an
exported DefUseAccumulator beside CallSiteFactAccumulator in
call-site-harvest.ts (the PR's own model for the with-site superset); the six
harvesters import it under their existing local FactAccumulator name. Pure
byte-equivalent move, no logic change: cfg unit suite 613 passed, tsc clean,
bench --check fingerprints unchanged (TS/Go paths untouched).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cfg): consolidate copied visitor-test helpers into cfg-harness (#2195 U8)
The 13 *-visitor.test.ts files each copied a byte-identical set of CFG-shape
helpers (edgeKinds/block/reaches/reachable/bindingIdx/allSites/hasAnySites,
~380 lines total). Export them once from test/helpers/cfg-harness.ts and import
per file (only the subset each references). Also drop each file's local
exitReachableFromAll — a re-implementation of the production
isExitReachableFromAllBlocks (semantically identical: false iff some
entry-reachable non-EXIT block can't reach EXIT) — and point its live call
sites at the already-imported production function. Pure test-only mechanical
move, behavior-preserving: tsc clean, test/unit/cfg/ 613 passed unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cfg): pin *-harvest.ts literals to their own grammar in the gate (#2195 U10)
The grammar-literal validation gate scans cfg/visitors/, but a <lang>-harvest.ts
basename was not in BASENAME_LANGS, so fileLanguages() fell it through to the
weak ALL_LANGS valid-if-any bucket — a node-type literal dead in its own grammar
but valid in some other grammar would pass undetected. Strip the -harvest suffix
and reuse the visitor basename map so go-harvest -> Go, c-cpp-harvest -> C+C++,
typescript-harvest -> TS, etc. The two language-agnostic harvesters
(call-site-harvest, scope-tree-harvest) name no grammar and stay valid-if-any.
Also corrects the now-inaccurate mode2Files comment. Adds a fileLanguages unit
test; the existing gate stays green (no harvest file has a dead literal), and a
scratch probe confirmed a bogus go-harvest literal is now caught.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cfg): defensive per-statement cap on harvested taint sites (#2195 U11)
A statement's harvested sites[] had no explicit bound — a pathological or
machine-generated statement (hundreds of nested calls) could grow it without
limit. Add DEFAULT_PDG_MAX_SITES_PER_STATEMENT (512, mirroring the PDG edge/fact
cap style): openCallSite/addMemberRead check-before-push and stop at the cap,
keeping the first 512 sites fully intact and setting an observable
sitesTruncated flag. A cap-dropped openCallSite returns a -1 sentinel that
pushFrame/setSite*/the occurrence fan-out all tolerate (no dangling parent/via,
no clobber of kept sites). Generous enough that no real statement is affected:
bench --check fingerprints unchanged, cfg unit suite 617 passed.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* ci(codeql): exclude nested test fixtures from the CodeQL gate (#2195)
The CodeQL results gate failed on test/integration/cfg/fixtures/python-hazards.py
('total' may be used before init, unused vars) — but that file is an intentional
CFG/PDG hazard fixture, exactly the synthetic broken-code the existing
'**/test/fixtures/**' exclusion is meant to skip. That glob does not match the
deeper test/integration/cfg/fixtures/ path, so the hazard fixtures leaked into
the scan. Add '**/test/**/fixtures/**' to cover fixtures nested anywhere under a
test tree. Analyze (python) and Analyze (javascript-typescript) both already pass
— production code is clean; this only silences fixture noise.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* style(cfg): apply prettier + drop unused imports across the PDG files (#2195)
The merge of main into this branch pulled in the stricter quality gates
(prettier --check . and eslint .), which surfaced pre-existing formatting in the
PDG/CFG rollout (line-width wrapping across the visitor + harvest files, bench,
tests) plus 9 no-unused-imports errors. Mechanical autofix only — npm run
format + lint:fix equivalent, scoped to gitnexus/: removes unused FunctionCfg/
SiteRecord type imports left by the U8 helper consolidation and stale
FinalizerFrame imports in python.ts/ruby.ts. No behavior change: tsc clean, cfg
unit suite 617 passed, eslint 0 errors. (gitnexus-web class-order noise is a
local tailwind-plugin artifact CI does not flag — left untouched.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest C++ structured-binding defs (auto [a,b]=e) (#2195)
The C++ def/use harvester only recorded a def when an init_declarator's
declarator was a plain identifier, so a structured binding (auto [a,b] = mk(),
incl. the auto& reference form whose binding sits under a reference_declarator)
declared only the first name in phase 1 and emitted ZERO defs in phase 2 — a,b
were walked as spurious uses and later use(a)/use(b) resolved to a synthetic
module binding, silently corrupting REACHING_DEF/taint for an idiomatic C++17
shape. Unwrap the structured_binding_declarator in both phases and def every
identifier leaf; result-of-initializer flows to the whole list. Inert for C
(no structured bindings). Characterization tests added (plain + reference form).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): model C++ co_return as a return terminator to EXIT (#2195)
co_return_statement was neither in CPP_CONTROL_FLOW_TYPES nor dispatched, so a
coroutine's co_return coalesced into a straight-line block and emitted a
spurious seq fallthrough to the following statement instead of an edge to EXIT
— statements after co_return looked reachable and the terminator edge was
missing, corrupting CFG/CDG for coroutines. Add the node type to the C++
control-flow set and dispatch it through visitReturn (block -> EXIT 'return',
no fallthrough). C path untouched; co_await/co_yield remain plain expressions.
Characterization test added; c-cpp suite + grammar-literal gate green, bench
--check unchanged.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest C# out-var and deconstruction-declaration defs (#2195)
Two idiomatic C# write shapes recorded ZERO defs, silently breaking
REACHING_DEF/taint:
- out-var (G(out var n) / G(out int n)) parses as a declaration_expression;
it was neither declared (phase 1) nor def'd (phase 2), so n resolved to a
synthetic module binding and the callee-written value had no reaching def.
- deconstruction declaration (var (a, b) = T()) has a variable_declarator whose
name slot is a tuple_pattern (null name field), so declareVariableDeclaration
+ the variable_declaration walk skipped it entirely (only the assignment form
(a,b)=T() was handled). Both a and b were dropped.
Declare + def the declaration_expression's identifier (must-def: out params are
definitely-assigned), and route a null-name variable_declarator through the
tuple_pattern via the existing declareForeachTarget/defTupleTargets helpers.
Characterization tests added; csharp suite 42 passed, grammar gate + bench
--check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): put embedded-script CFGs in file coordinates via lineOffset (#2195)
A Vue SFC <script> block parses at row 0 but lives at lineOffset in the .vue
file. Every other worker-emitted graph node adds lineOffset to reach file
coordinates, but collectFunctionCfgs built FunctionCfgs from the extracted
script's raw rows and never offset them. Two consequences for .vue files:
- inter-procedural taint silently resolved NOTHING — the summary-harvest join
keys graph Function/Method nodes by their (offset) startLine but looked up the
CFG's (unoffset) functionStartLine, missing by exactly lineOffset, so no
FunctionSummary was ever produced;
- persisted BasicBlock startLine/endLine (and the id's functionStartLine
segment) pointed at the wrong .vue line, breaking source mapping.
Thread lineOffset into collectFunctionCfgs and shift every CFG source-line field
(functionStartLine/End, block start/end, statement + non-synthetic binding
lines) into file coordinates at the one production chokepoint. A 0 offset
returns the CFG unchanged, so .ts/.js/etc. stay byte-identical (bench --check
fingerprints unchanged; worker-roundtrip + pipeline-pdg green). Unit tests for
the shift + the 0-offset no-op added.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cfg): surface CDG soundness skips at warn, not just debug (#2195)
skippedUnsoundFunctions (a function whose EXIT is not reverse-reachable from
all blocks, so control dependence is withheld) was only reported inside the
per-language logger.debug stats line — while the taint/RD coverage-gap and
cap-drop counts surface unconditionally at warn. A language that systematically
trapped EXIT (an unmodeled non-terminating / multi-terminal shape the
synthetic-escape pass can't bridge) would silently lose all CDG. Add a parallel
unconditional warn (R8) alongside the R4 taint-gap warn. Observability only —
no graph change; emit-layer skip counting stays covered by cfg-emit's
skippedUnsoundFunctions test.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest Kotlin x++/--x as a def (#2195)
The Kotlin harvester had no postfix_expression/prefix_expression case, so an
increment/decrement fell to the default descent and recorded its operand as a
use only — never a def. Every sibling harvester (Java/C#/C++/Dart/TS/PHP) models
inc/dec, so a Kotlin counting loop (while/for using i++) silently dropped the
loop-carried reaching-def of the counter. Add the case: def AND use the operand
when it is a plain simple_identifier and the operator is ++/-- (other pre/postfix
forms — -x, !x, x!!, x? — stay pure reads, byte-identical to the old descent).
Characterization tests for postfix + prefix added; kotlin suite 30 passed,
grammar gate + bench --check green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest Go select channel-receive binding as a def (#2195)
walkValue had no receive_statement case, so a select receive (case v := <-ch:)
fell to the default descent: v was recorded as a USE of an uninitialized var
and the channel-sourced definition was invisible to REACHING_DEF/taint —
channels are a primary taint source in Go. Add the case mirroring
short_var_declaration: def each left identifier, use the <-ch right, attach
resultDefs for the := short form. prescan already declared the binding; this
completes the phase-2 fact. go:branchy bench fingerprint unchanged; go suite
40 passed, grammar gate green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest all names of a Dart multi-variable declaration (#2195)
`var a = 1, b = 2;` is one initialized_variable_definition whose first binding
is the name/value field pair and whose subsequent bindings are trailing
initialized_identifier children. Both prescan (declareInitializedVar) and the
walkValue case read only the name/value fields, so every name after the first
was never declared or def'd — `b` resolved to a synthetic module binding and
its REACHING_DEF/taint flow was lost. Iterate the trailing initialized_identifier
nodes in both phases. (Dart-3 record/list pattern declarations `var (a,b)=pair`
remain a separate follow-up.) dart suite 35 passed, tsc + grammar gate green.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): bind Swift switch-case value patterns (case let n) (#2195)
A switch value-binding (case let n where …, case .some(let v)) was never
declared in prescan, so n/v resolved to a synthetic module binding and a body
use(n) did not link to any def — a very common Swift idiom silently lost its
data dependence. Declare the switch_pattern's bindings (prescan, reusing
declarePattern) and emit them as MAY-defs on the dispatch block (a case may not
match) via a new switchPatternFacts, propagated into the case body. swift suite
25 passed, tsc + grammar gate green. (The rare ?? / ternary-arm may-def — Swift
assignment-as-expression — remains a separate follow-up.)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): wire Swift multi-catch throw edges to every handler (#2195)
visitDo routed the protected body's throw edge only to handlerEntries[0], so a
do { try r() } catch A {} catch {} left the 2nd..Nth catch handlers UNREACHABLE
from ENTRY — orphaned blocks whose error bindings + def/use facts were stranded
in a dead component (a soundness gap for idiomatic Swift typed multi-catch).
Swift tries the catch clauses in order and the thrown type is unknown at CFG
time, so every protected block may reach ANY clause: edge each protected block
to every handlerEntry. Found by the per-language CFG/CDG verification swarm
(reproduced: 2-catch=1, 3-catch=3 unreachable blocks). swift suite 26 passed,
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): synthesize a protected block for an empty Kotlin try {} (#2195)
An empty `try {}` body produced zero protected blocks, so visitTry's throw-edge
loop wired nothing to the catch and the try's entry fell through to the finally
— leaving the catch handler block + its error binding orphaned (unreachable from
ENTRY), a malformed CFG with stranded def/use facts. Mirror the existing
empty-`catch` synthesis: when the try body is empty and there is a catch or
finally, synthesize one protected block so the catch handler(s) are wired and
the try entry is the body, not the finally. Found by the per-language CFG
verification swarm. Non-empty try is byte-identical; kotlin suite 31 passed,
tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(cfg): bound the reaching-defs fixpoint with a per-block visit ceiling (#2195)
The per-language verification swarm reproduced, AT PRODUCTION DEFAULTS, a
reaching-defs blow-up: a machine-generated ~2000-line all-loops function (under
DEFAULT_PDG_MAX_FUNCTION_LINES) reaches ~10k basic blocks because loops emit ~5
blocks/line, and the dataflow fixpoint is O(blocks^2.3) on deep loop nests —
measured 62s (C/C++) and 2.05s + 810MB (Go) for ONE function. maxFacts does not
help: the fact count stays LINEAR, so it never fires.
Iterative reaching-defs on a reducible CFG converges in O(loop-nesting-depth)
passes, so a worklist re-visits each block a small multiple of times for real
code. Add a maxBlockVisits ceiling (emit passes blocks.length × 64 — far beyond
any hand-written nesting depth, ~15) that bails when the fixpoint has not
converged. An unconverged fixpoint's in/out sets are not sound, so it returns
NO facts (status 'truncated', like the existing 'overflow' guard) — a per-
function coverage gap, never wrong facts. Real code is byte-identical: full cfg
suites 725 passed, bench --check fingerprints unchanged.
NOTE: computeControlDependence's O(N²) up-walk on deep post-dom chains is the
sibling concern but stays ~13ms in production (bounded by the line cap + the
CDG materialization cap); a CDG work-budget is a documented follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): raise the parse-worker stack limit for deep CFG recursion (#2195)
The CFG visitors build per-function control-flow graphs by recursive descent
over the tree-sitter AST, so deeply-nested source overflows the worker thread's
call stack (~1.5k nesting levels) — caught per-function (R4 try/catch) but the
function silently gets no PDG. A worker thread's stack is governed by
resourceLimits.stackSizeMb (Node default 4 MB); the main process's
--stack-size=4096 flag does NOT propagate to worker threads (confirmed by prior-
art research on Node worker_threads). Raise it to 16 MB, pushing the overflow
threshold to several-thousand nesting levels — far beyond any hand-written code,
so only machine-generated/obfuscated nesting can still hit it (and that stays a
caught per-function skip, never a crash). Complements a future proactive depth
guard. pipeline-pdg worker tests 30 passed, tsc clean.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* perf(cfg): compute control dependence as a reverse-CFG dominance frontier (#2195)
The Ferrante §3.1.1 up-walk re-climbed the ipdom chain once per CFG edge,
which is Θ(N²) on a deep post-dom chain (a single branch fanning into a
shared spine took ~7.1s at 16k blocks). Replace it with the reverse-CFG
post-dominance-frontier formulation (Cytron, Ferrante, Rosen, Wegman &
Zadeck 1991): control dependence IS the dominance frontier of the reverse
CFG, computed bottom-up over the post-dom tree (PDF_local from a node's CFG
in-edges + PDF_up from its post-dom-tree children) in O(N + E + output).
LLVM (ReverseIDFCalculator), Joern (CdgPass) and WALA use the same form.
Output is the IDENTICAL deduped/sorted (controller, dependent, label) set:
verified byte-identical across all cfg unit+integration suites, the
cdg-snapshot oracle, and bench --check fingerprints (unchanged). The PDF
unions a label SET per (controller, dependent) pair, preserving the
multi-label rows the old per-row dedup kept on opposite-sense (goto-cycle)
arms. buildArmSenses, labelFor, the final sort and the maxEdges truncation
cap are kept verbatim; the post-order walk is iterative so a chain-deep
post-dom forest cannot overflow the stack.
Adds three regressions: multi-label-per-pair preservation, the literal
self-edge / NO_IPDOM seed guard (a !== x), and a fan-into-chain perf
tripwire (linear vs the former quadratic up-walk).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(cfg): record the reaching-defs WTO no-go decision (#2195)
Weak-topological-order / loop-aware iteration (Bourdoncle 1993) was
evaluated as the fix for the O(blocks²) deep-loop-nest blow-up and
rejected: a faithful WTO solver was 104/104 byte-identical to the RPO
worklist but 0% faster — the cost is inherent dense-set propagation +
lattice merges, not visitation order, and the loop-body-skip shortcut is
unsound on irreducible (goto) CFGs. Document this at the RPO-order site
and the emit.ts revisit-ceiling constant so the shipped blocks×64 bound
reads as the sound backstop it is, with SSA-sparse reaching-defs named as
the deferred real fix. Comment-only; no behavior change.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* feat(cfg): proactive visitor nesting-depth guard + observable CFG skips (#2195)
The CFG visitors are recursive-descent with no shared base, so a
pathologically nested function (machine-generated / adversarial) could
overflow the worker's native stack — a nondeterministic RangeError that
escaped to the language-group catch and silently dropped EVERY remaining
file's CFG.
Guard it proactively: CfgBuilder tracks live recursive-descent nesting
depth via enterNesting/exitNesting, called at each visitor's visitBody and
visitSeq choke points (visitBody covers nested control constructs incl.
else-if ladders; visitSeq covers deeply-nested bare blocks). Exceeding
MAX_CFG_NESTING_DEPTH (500, far below the ~1.2k+ native limit and far above
real code's ≤~50) throws a typed, DETERMINISTIC CfgNestingDepthError instead
of waiting for the engine's nondeterministic overflow.
collectFunctionCfgs now isolates the build PER FUNCTION: the depth bail or
any other throw is caught, counted, and skipped — one bad function no longer
loses the whole file's CFGs. CollectedCfgs.skipped widens from a bare number
to reason-counted buckets (tooManyLines / tooDeeplyNested / buildError). The
worker stops discarding that count (parse-worker.ts), aggregates it
per-language onto ParseWorkerResult.cfgSkipped (survives the parse cache via
slim's `...result`), and mergeChunkResults merges + warns per-language so a
CFG coverage gap is observable, not silent.
Behavior-preserving on normal code: the guard never fires below 500 nesting,
so the cfg unit+integration suites (731), the CDG/RD/CFG snapshots and the
bench --check fingerprints are all byte-identical. The worker stackSizeMb
4→16MB bump shipped earlier (de4c43a4).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* refactor(cfg): unify the nesting guard behind CfgBuilder.withNesting (#2195)
Tri-review flagged the visitBody/visitSeq guard asymmetry (visitBody used
try/finally; visitSeq placed a bare exitNesting before its tail return). It
is not a bug today — the CfgBuilder is per-function and discarded on a bail,
so a leaked counter is never read — but a future mid-loop return in visitSeq
would silently corrupt the depth count. Replace both hand-paired sites in all
12 visitors with a single `CfgBuilder.withNesting(fn)` helper that enters on
the way in and exits in a finally, so the pair can never drift. Also document
that block-bodied constructs pass through BOTH choke points, so the effective
lexical ceiling is ~MAX_CFG_NESTING_DEPTH/2 (~250).
Behavior-preserving: 733 cfg tests + bench --check fingerprints byte-identical.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): include cfgSkipped in all parse-worker result initializers (#2195)
Tri-review found cfgSkipped omitted from the two reset/fallback
ParseWorkerResult initializers (only the main one carried it). The field is
optional with `?? {}` reads so there is no runtime bug, but the zero-state
initializers should be complete and consistent. Also correct the field's
doc-comment: the per-language merge + warn lives in `dispatchChunkParse`
(alongside skippedLanguages), not `mergeChunkResults` — and, like that
sibling telemetry, the warn fires for freshly-parsed chunks, not on a warm
cache hit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* docs(cfg): scope the CDG byte-identical claim to the untruncated output (#2195)
Tri-review noted the rewrite's `maxEdges` truncation now trims the SORTED
edge set, whereas the old up-walk broke mid-walk in CFG-edge-iteration order
— so a truncated PREFIX can differ at the cap boundary (only when a single
function exceeds maxEdges; the FULL untruncated set is byte-identical, now
also confirmed by ~1M-case differential fuzz). Clarify the module doc and the
maxEdges param doc: the cap bounds OUTPUT count (peak working set ≈ output in
the DF formulation, not the old pre-dedup spike), and the byte-identical
guarantee is scoped to the untruncated output. Comments only.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* test(cfg): strengthen depth-guard, perf-tripwire and buildError coverage (#2195)
Tri-review test-quality findings:
- cfg-builder: capture the thrown CfgNestingDepthError unconditionally (a
catch-only assertion silently passes if a future change stops throwing);
add a withNesting test that the counter balances on the THROW path too.
- control-dependence perf tripwire: assert controller IDENTITY (every edge
controlled by block 0, distinct dependents in range), not just length, so a
fast-but-wrong reimplementation can't pass on the M-1 count alone.
- worker-roundtrip: add the missing buildError test — a generic (non-depth)
buildFunctionCfg throw is caught per function, counted under buildError, and
does NOT drop the file's sibling CFGs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): model Kotlin value-position when/if as control dependence (#2205)
Idiomatic Kotlin uses `if`/`when` as EXPRESSIONS (`val x = when (k) { … }`,
`return if (c) a else b`, `fun f() = when (k) { … }`), but the visitor only
modeled them as control flow in statement position (the `isStatementPosition`
gate) — value-position branches collapsed into one straight-line block, so
their arms emitted no control dependence. Cross-language PDG validation
(#2195, PR #2197) measured the result: two Kotlin repos at 3% / 6% CDG per
BasicBlock vs 18–76% for every other language (incl. the other
expression-conditional languages, Rust 30% / Swift 24%).
Model value-position `when` (≥2 arms) and `if`/`else` as control flow in the
three dominant carriers — `property_declaration` (rejoin the arms at a
binding continuation carrying the bound name's def), `return`, and the
`fun f() = …` expression body (each arm returns) — mirroring the Rust
visitor's value-position `let` handling. `visitWhen`/`visitIf` are reused
unchanged; `isControlFlow` now routes a value-branch `val`/`var` decl to the
branch handler instead of coalescing it.
Measured: Exposed CDG 3636→5644 (+55%, 6%→9% of BasicBlocks), turbine
67→82 (+22%). Argument-position branches, assignment RHS, and value-position
`try` are left inline — a remaining gap tracked on #2205.
Behavior-preserving for non-Kotlin (bench --check byte-identical; 739 cfg
tests incl. 6 new value-position regressions).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): model Ruby value-position if/case on assignment RHS as control dependence (#2195)
`if`/`case` are expressions in Ruby; as an `assignment` RHS
(`x = if c then a else b end`, `x = case k … end`) the visitor previously
left the arms INLINE in one coalesced block (a gap documented at ruby.ts
§"if/case/begin are EXPRESSIONS"), emitting no control dependence. Model the
RHS branch as control flow and bind the LHS at the rejoin: `assignmentBranch`
detects the carrier, `visitSeq` routes it out of the coalescing path, and
`visitBindBranch` reuses `visitIf`/`visitCase` + a facts-only continuation
carrying the LHS def (new `harvest.assignmentDefFacts`). Mirrors the Kotlin
(#2205) and Rust value-position handling.
Honest impact: SMALL in practice — rack CDG 1269→1303, sinatra 1212→1232
(~+2–3%). `x = if/case` is far rarer in idiomatic Ruby than the Kotlin
analog (Ruby favors ternary / `||=` / guard modifiers), and Ruby's low
CDG/BB is mostly structural (micro-branches `&&`/`||`/`?:`/`&.` excluded by
design, plus many straight-line `.each`/`.map` block CFGs). This closes the
documented gap correctly; it is not a large ratio mover. Explicit
`return if … end` is NOT a carrier — tree-sitter-ruby drops that value; the
idiomatic implicit-last-expression conditional was already modeled.
Behavior-preserving for non-Ruby (bench --check byte-identical; 744 cfg
tests incl. 5 new value-position regressions).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): keep an empty-arm Kotlin when wired to the join (#2195)
An all-empty-arm `when` with an `else` — `when(k){0->{};else->{}}`, idiomatic
`else -> {}` "do nothing" — left the dispatch block with ZERO successors:
empty arms got no `switch-case` edge, and the `else` suppressed the no-match
edge. The dispatch and its join then became orphaned, so
isExitReachableFromAllBlocks returned false and emitFileCdg silently dropped
the ENTIRE function's control dependence (counted cdgSkippedUnsound). The
#2205 value-position fix newly routes `val x = when(…)` / `return when(…)` /
`fun f() = when(…)` through visitWhen, exposing it in those carriers too.
Wire every arm (empty or not) to the join, so the dispatch always has a
successor. Found by the per-language CFG verification swarm. Behavior-
preserving elsewhere (bench --check byte-identical; cfg suite green).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): wire C/C++ throw edges to every catch handler, not just the first (#2195)
`visitTry` edged every protected-region block only to `handlerEntries[0]`, so
in a multi-`catch` (`catch(int e){…} catch(double d){…} catch(...){…}`) the
2nd..Nth handlers were orphaned — unreachable from ENTRY, their catch-param
binding and body control/data flow silently lost. The runtime catch that
matches a thrown type is not statically known, so over-approximate: edge each
protected block to EVERY handler entry (mirrors the Swift multi-catch
handling). Found by the per-language CFG verification swarm; the two existing
exception tests both used a single catch, so it was never exercised.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): keep a bare continue in a Dart switch case — it targets the loop (#2195)
`caseStatements` stripped EVERY `continue_statement` from a case body, but only
a LABELED `continue LABEL;` is a switch fallthrough-spill (handled via
caseContinueLabel). A bare `continue;` targets the ENCLOSING LOOP (valid Dart);
dropping it removed the jump and fabricated a false case → next-statement
fall-through edge (e.g. `case 1: tainted(); continue; default: sink();` made
tainted() flow directly into sink()). Only strip the labeled form; a bare
`continue;` stays in the body and routes to the loop. Found by the per-language
CFG verification swarm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest Rust match-arm pattern bindings as defs (taint propagation) (#2206)
`visitMatch` visited arm bodies but never harvested the arm PATTERN's bindings,
so `match x { Some(n) => sink(n) }` left `n` with a use and no def/may-def —
taint from the matched subject could not propagate into the arm. Add
`matchArmPatternFacts` (the binders as MAY-defs, since only the matching arm
binds) and attach it to the dispatch block, co-located with the subject's use.
Found by the per-language CFG verification swarm; the match tests asserted
`hasUse` but never `hasDef`.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): harvest Swift guard case / if case enum-pattern bindings as locals (#2206)
`guard case .some(let v) = e` / `if case let .x(n) = e` nest the binder inside a
`pattern` condition child, not a direct `bound_identifier`. Both the declaration
(declareOptionalBindings) and the def-facts (conditionFacts, which ran walkValue
= a USE on the pattern) missed it, so the binding resolved to a synthetic
`@module` global with a use and no def — breaking taint propagation from the
subject. Declare the `pattern` child and def its leaves (may-def when
conditional). Found by the per-language CFG verification swarm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): treat Dart switch-expression arm writes as may-defs, not hard kills (#2206)
`DartHarvester.walkValue` had no `switch_expression` case, so an assignment in an
arm value — `var y = switch(x){ 1 => z = 10, _ => z = 20 }` — became an
unconditional def that KILLED the prior `z`, even though only one arm runs (the
module docstring claimed it was a may-def, but the code didn't implement it).
Walk the subject always and each `switch_expression_case` under `conditional(…)`,
so arm writes are may-defs — mirroring `conditional_expression`. Found by the
per-language CFG verification swarm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
* fix(cfg): model the C# `using var` declaration-form dispose finalizer (#2206)
`using var f = Open();` (C# 8) parses as a `local_declaration_statement` with a
leading `using` keyword, not a `using_statement`, so visitUsing never ran — no
dispose block, and a `return`/`break`/`continue` in its scope got no
`finally-*` completion edge. Unlike the delimited block form, its dispose runs
at ENCLOSING-SCOPE exit, so visitSeq now treats the REST of the sequence as the
protected body: the acquisition (`var f = e`) is a normal block outside the
dispose region (a throw there means the resource was never acquired), and
`buildUsingDeclScope` wraps the remainder in a synthetic dispose finalizer
(normal + exception exit, early exits thread through) — mirroring
buildProtectedSynthetic. Closes the last #2206 item. Found by the per-language
CFG verification swarm.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
536 lines
22 KiB
TypeScript
536 lines
22 KiB
TypeScript
import { describe, it, expect } from 'vitest';
|
||
import {
|
||
computeControlDependence,
|
||
type ControlDepEdge,
|
||
type CdgLabel,
|
||
} from '../../../src/core/ingestion/cfg/control-dependence.js';
|
||
import {
|
||
computePostDominators,
|
||
isExitReachableFromAllBlocks,
|
||
postDominates,
|
||
} from '../../../src/core/ingestion/cfg/post-dominators.js';
|
||
import { augmentForPostDom } from '../../../src/core/ingestion/cfg/synthetic-escape.js';
|
||
import type {
|
||
BasicBlockData,
|
||
CfgEdgeData,
|
||
CfgEdgeKind,
|
||
FunctionCfg,
|
||
} from '../../../src/core/ingestion/cfg/types.js';
|
||
import Parser from 'tree-sitter';
|
||
import TypeScript from 'tree-sitter-typescript';
|
||
import { collectFunctionCfgs } from '../../../src/core/ingestion/cfg/collect.js';
|
||
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
|
||
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
|
||
|
||
// U3 (#2085 M5) — Ferrante §3.1.1 control dependence over the post-dom tree.
|
||
// Hand-built CFG literals plus real-parser regression tests. The labelled
|
||
// expected edge sets ARE the spec; the property test (AC2) cross-checks the
|
||
// tree-walk against a reference that computes post-dominance INDEPENDENTLY (by
|
||
// node-removal reachability, sharing NO code with post-dominators.ts), so a
|
||
// post-dominator *direction* bug cannot pass both (#2188 F4).
|
||
|
||
// ── hand-built CFG helper (edges carry a kind so labels can be asserted) ─────
|
||
|
||
function mkCfg(
|
||
blockCount: number,
|
||
edges: [number, number, CfgEdgeKind][],
|
||
opts: { entry?: number; exit?: number } = {},
|
||
): FunctionCfg {
|
||
const entry = opts.entry ?? 0;
|
||
const exit = opts.exit ?? blockCount - 1;
|
||
const blocks: BasicBlockData[] = Array.from({ length: blockCount }, (_, i) => ({
|
||
index: i,
|
||
startLine: i + 1,
|
||
endLine: i + 1,
|
||
text: '',
|
||
kind: i === entry ? 'entry' : i === exit ? 'exit' : 'normal',
|
||
}));
|
||
const cfgEdges: CfgEdgeData[] = edges.map(([from, to, kind]) => ({ from, to, kind }));
|
||
return {
|
||
filePath: 't.ts',
|
||
functionStartLine: 1,
|
||
functionStartColumn: 0,
|
||
entryIndex: entry,
|
||
exitIndex: exit,
|
||
blocks,
|
||
edges: cfgEdges,
|
||
};
|
||
}
|
||
|
||
const ser = (e: ControlDepEdge): string => `${e.controllerBlock}->${e.dependentBlock}:${e.label}`;
|
||
const serAll = (edges: readonly ControlDepEdge[]): string[] => edges.map(ser);
|
||
|
||
/**
|
||
* Build a successor adjacency list for a CFG (in-range edges only).
|
||
*/
|
||
function succsOf(cfg: FunctionCfg): number[][] {
|
||
const n = cfg.blocks.length;
|
||
const succs: number[][] = Array.from({ length: n }, () => []);
|
||
for (const e of cfg.edges)
|
||
if (e.from >= 0 && e.from < n && e.to >= 0 && e.to < n) succs[e.from].push(e.to);
|
||
return succs;
|
||
}
|
||
|
||
/**
|
||
* INDEPENDENT post-dominance via node-removal reachability — shares NO code with
|
||
* post-dominators.ts (that is the whole point: it must catch a CHK *direction*
|
||
* bug that a shared-substrate reference would mirror, #2188 F4). `p`
|
||
* post-dominates `b` iff every path from `b` to EXIT passes through `p`:
|
||
* reflexive (`p === b`), else true exactly when EXIT is unreachable from `b`
|
||
* once `p` is removed (AND `b` can reach EXIT at all). Defined only for the
|
||
* exit-reachable fixtures used below. A raw exit-unreachable cycle (#2188 F2)
|
||
* would be unsound, so the AC2 set now feeds the synthetic-escape pass's
|
||
* AUGMENTED view of every goto-cycle fixture (#2197 U1) — once bridged it IS
|
||
* exit-reachable and the Ferrante walk must equal this independent reference.
|
||
*/
|
||
function independentPostDom(cfg: FunctionCfg, succs: number[][], p: number, b: number): boolean {
|
||
if (p === b) return true;
|
||
const exit = cfg.exitIndex;
|
||
const reach = (avoid: number): boolean => {
|
||
if (b === avoid) return false;
|
||
const seen = new Set<number>([b]);
|
||
const stack = [b];
|
||
while (stack.length) {
|
||
const x = stack.pop()!;
|
||
if (x === exit) return true;
|
||
for (const y of succs[x]) {
|
||
if (y === avoid || seen.has(y)) continue;
|
||
seen.add(y);
|
||
stack.push(y);
|
||
}
|
||
}
|
||
return false;
|
||
};
|
||
// p post-dominates b ⇔ b reaches EXIT, but cannot reach it with p removed.
|
||
return reach(-1) && !reach(p);
|
||
}
|
||
|
||
/**
|
||
* Reference control-dependence pairs from the Ferrante definition, using the
|
||
* INDEPENDENT post-dominance above: N is control-dependent on A iff some CFG
|
||
* edge A→B has N post-dominating B while N does NOT strictly post-dominate A.
|
||
* Label-agnostic (distinct "A->N" pairs) — the pure definition has no sense.
|
||
*/
|
||
function referencePairs(cfg: FunctionCfg): Set<string> {
|
||
const n = cfg.blocks.length;
|
||
const succs = succsOf(cfg);
|
||
const pd = (x: number, y: number): boolean => independentPostDom(cfg, succs, x, y);
|
||
const pairs = new Set<string>();
|
||
for (let a = 0; a < n; a++) {
|
||
for (const b of succs[a]) {
|
||
if (pd(b, a)) continue; // edge is not a control point
|
||
for (let nn = 0; nn < n; nn++) {
|
||
const nPostDomB = pd(nn, b);
|
||
const nStrictlyPostDomA = nn !== a && pd(nn, a);
|
||
if (nPostDomB && !nStrictlyPostDomA) pairs.add(`${a}->${nn}`);
|
||
}
|
||
}
|
||
}
|
||
return pairs;
|
||
}
|
||
|
||
describe('computeControlDependence — Ferrante §3.1.1', () => {
|
||
it('diamond: each arm is control-dependent on the branch with its own T/F label', () => {
|
||
// 0(branch) → 1(then, T), 2(else, F); 1,2 → 3(join) → 4(exit)
|
||
const cfg = mkCfg(5, [
|
||
[0, 1, 'cond-true'],
|
||
[0, 2, 'cond-false'],
|
||
[1, 3, 'seq'],
|
||
[2, 3, 'seq'],
|
||
[3, 4, 'seq'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
expect(serAll(edges).sort()).toEqual(['0->1:T', '0->2:F']);
|
||
// the join (3) post-dominates the branch, so it depends on nothing
|
||
expect(edges.some((e) => e.dependentBlock === 3)).toBe(false);
|
||
});
|
||
|
||
it('guard clause: the post-guard body is control-dependent on the guard (the #559/#2086 case)', () => {
|
||
// function f(x){ if(!ok(x)) return; use(x); }
|
||
// 0(entry) → 1(guard); 1 → 2(return, T) , 1 → 3(use, F); 2,3 → 4(exit)
|
||
const cfg = mkCfg(5, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'], // !ok(x) → return
|
||
[1, 3, 'cond-false'], // else → use(x)
|
||
[2, 4, 'return'],
|
||
[3, 4, 'seq'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
// use(x) (block 3) runs only when the guard condition is false → label 'F'
|
||
expect(serAll(edges).sort()).toEqual(['1->2:T', '1->3:F']);
|
||
});
|
||
|
||
it('straight-line function (no branches) has no control dependence', () => {
|
||
const cfg = mkCfg(3, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'seq'],
|
||
]);
|
||
expect(computeControlDependence(cfg).edges).toEqual([]);
|
||
});
|
||
|
||
it('while loop: the body depends on the header, and the header is control-dependent on itself', () => {
|
||
// 0(entry) → 1(header); 1 → 2(body, T) , 1 → 3(exit, F); 2 → 1 (back-edge)
|
||
const cfg = mkCfg(4, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'],
|
||
[2, 1, 'loop-back'],
|
||
[1, 3, 'cond-false'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
// body(2) control-dep on header(1); header(1) control-dep on itself (the
|
||
// loop predicate gates its own re-execution — standard PDG behavior).
|
||
expect(serAll(edges).sort()).toEqual(['1->1:T', '1->2:T']);
|
||
});
|
||
|
||
it('switch: every case body is control-dependent on the dispatch (all T in M5)', () => {
|
||
// 0(entry) → 1(dispatch); 1 → 2,3,4 (cases); 2,3,4 → 5(exit)
|
||
const cfg = mkCfg(6, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'switch-case'],
|
||
[1, 3, 'switch-case'],
|
||
[1, 4, 'switch-case'],
|
||
[2, 5, 'break'],
|
||
[3, 5, 'break'],
|
||
[4, 5, 'break'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
expect(serAll(edges).sort()).toEqual(['1->2:T', '1->3:T', '1->4:T']);
|
||
});
|
||
|
||
it('exit-less loop (KTD5): terminates and stays in-range, but the result is KNOWN-UNSOUND (#2188 F2)', () => {
|
||
// No block can reach EXIT (block 3), so every ipdom is NO_IPDOM and the
|
||
// Ferrante walk degenerates to one edge per control point. The termination /
|
||
// in-range invariants MUST hold (the walk hits NO_IPDOM immediately). The
|
||
// emitted dependence SET, however, is NOT a sound over-approximation: it both
|
||
// drops real dependences and invents spurious ones in exit-unreachable
|
||
// regions (#2188 F2). This test pins the degenerate output to document that
|
||
// behavior, NOT to bless it; the labels here are likewise indeterminate
|
||
// (no controller carries an explicit cond-true/cond-false arm, so the
|
||
// fall-through complement resolves to 'F'). The current TS visitor never
|
||
// produces such a region (every loop gets a structural header→loopExit edge).
|
||
const cfg = mkCfg(4, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'seq'],
|
||
[2, 1, 'loop-back'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
expect(serAll(edges).sort()).toEqual(['0->1:F', '1->2:F', '2->1:F']);
|
||
for (const e of edges) {
|
||
expect(e.controllerBlock).toBeGreaterThanOrEqual(0);
|
||
expect(e.controllerBlock).toBeLessThan(cfg.blocks.length);
|
||
expect(e.dependentBlock).toBeGreaterThanOrEqual(0);
|
||
expect(e.dependentBlock).toBeLessThan(cfg.blocks.length);
|
||
}
|
||
});
|
||
|
||
it('is deterministic (stable sorted order across runs)', () => {
|
||
const make = (): FunctionCfg =>
|
||
mkCfg(5, [
|
||
[0, 1, 'cond-true'],
|
||
[0, 2, 'cond-false'],
|
||
[1, 3, 'seq'],
|
||
[2, 3, 'seq'],
|
||
[3, 4, 'seq'],
|
||
]);
|
||
expect(serAll(computeControlDependence(make()).edges)).toEqual(
|
||
serAll(computeControlDependence(make()).edges),
|
||
);
|
||
});
|
||
|
||
describe('AC2 — a control dependence exists iff post-dominance fails for the branch', () => {
|
||
const fixtures: Record<string, FunctionCfg> = {
|
||
diamond: mkCfg(5, [
|
||
[0, 1, 'cond-true'],
|
||
[0, 2, 'cond-false'],
|
||
[1, 3, 'seq'],
|
||
[2, 3, 'seq'],
|
||
[3, 4, 'seq'],
|
||
]),
|
||
guard: mkCfg(5, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'],
|
||
[1, 3, 'cond-false'],
|
||
[2, 4, 'return'],
|
||
[3, 4, 'seq'],
|
||
]),
|
||
loop: mkCfg(4, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'],
|
||
[2, 1, 'loop-back'],
|
||
[1, 3, 'cond-false'],
|
||
]),
|
||
// Escaped `goto`-cycle (#2197 U1): after the synthetic-escape pass the
|
||
// exit-unreachable cycle is bridged and the dependence set becomes a SOUND
|
||
// over-approximation, so it now joins the AC2 set (the obsolete
|
||
// exit-unreachable exclusion is lifted — see the AUGMENTED-view note below).
|
||
// Repro shape: ENTRY=0, EXIT=1, b2=`(a>0)` predicate, b3=`work()`,
|
||
// b4=`goto start`; the `if` predicate (b2) is the only control point.
|
||
gotoCycle: augmentForPostDom(
|
||
mkCfg(
|
||
5,
|
||
[
|
||
[0, 2, 'seq'],
|
||
[2, 3, 'cond-true'],
|
||
[2, 4, 'seq'],
|
||
[3, 4, 'seq'],
|
||
[4, 2, 'seq'],
|
||
],
|
||
{ entry: 0, exit: 1 },
|
||
),
|
||
),
|
||
// Spine before the goto label — ENTRY + straight-line stmts are in the
|
||
// exit-unreachable closure but must reach EXIT after the bridge.
|
||
gotoCycleSpine: augmentForPostDom(
|
||
mkCfg(
|
||
7,
|
||
[
|
||
[0, 2, 'seq'],
|
||
[2, 3, 'seq'],
|
||
[3, 4, 'seq'],
|
||
[4, 5, 'cond-true'],
|
||
[4, 6, 'seq'],
|
||
[5, 6, 'seq'],
|
||
[6, 4, 'seq'],
|
||
],
|
||
{ entry: 0, exit: 1 },
|
||
),
|
||
),
|
||
// nested if: outer branch (0) → inner branch (1) or outer-else (5);
|
||
// inner branch → 2/3 → inner join (4); 4 and 5 → outer join (6, exit).
|
||
nestedIf: mkCfg(
|
||
7,
|
||
[
|
||
[0, 1, 'cond-true'],
|
||
[0, 5, 'cond-false'],
|
||
[1, 2, 'cond-true'],
|
||
[1, 3, 'cond-false'],
|
||
[2, 4, 'seq'],
|
||
[3, 4, 'seq'],
|
||
[4, 6, 'seq'],
|
||
[5, 6, 'seq'],
|
||
],
|
||
{ entry: 0, exit: 6 },
|
||
),
|
||
switchStmt: mkCfg(6, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'switch-case'],
|
||
[1, 3, 'switch-case'],
|
||
[1, 4, 'switch-case'],
|
||
[2, 5, 'break'],
|
||
[3, 5, 'break'],
|
||
[4, 5, 'break'],
|
||
]),
|
||
};
|
||
|
||
it.each(Object.keys(fixtures))(
|
||
'%s: is exit-reachable from all blocks (the AC2 reference is well-defined)',
|
||
(name) => {
|
||
// Every AC2 fixture — including the AUGMENTED goto-cycle ones (#2197 U1)
|
||
// — must be exit-reachable, else the node-removal reference is undefined.
|
||
expect(isExitReachableFromAllBlocks(fixtures[name])).toBe(true);
|
||
},
|
||
);
|
||
|
||
it.each(Object.keys(fixtures))(
|
||
'%s: tree-walk pair set equals the brute-force reference',
|
||
(name) => {
|
||
const cfg = fixtures[name];
|
||
const { edges } = computeControlDependence(cfg);
|
||
const walkPairs = new Set(edges.map((e) => `${e.controllerBlock}->${e.dependentBlock}`));
|
||
expect(walkPairs).toEqual(referencePairs(cfg));
|
||
},
|
||
);
|
||
|
||
it.each(Object.keys(fixtures))(
|
||
'%s: for every CFG edge, it yields a dependent IFF the target does not post-dominate the source',
|
||
(name) => {
|
||
const cfg = fixtures[name];
|
||
const tree = computePostDominators(cfg);
|
||
const { edges } = computeControlDependence(cfg);
|
||
for (const e of cfg.edges) {
|
||
const failsPostDom = !postDominates(tree, e.to, e.from);
|
||
// does THIS edge's source appear as a controller with at least one
|
||
// dependent reachable from its target? Equivalent statement of AC2:
|
||
// post-dominance failing for (from→to) ⇔ `from` is a control point.
|
||
const fromIsControlPoint = edges.some((c) => c.controllerBlock === e.from);
|
||
if (failsPostDom) {
|
||
expect(
|
||
fromIsControlPoint,
|
||
`${name}: edge ${e.from}->${e.to} should make ${e.from} a control point`,
|
||
).toBe(true);
|
||
}
|
||
// and a self-post-dominating edge (to post-dominates from) can never
|
||
// be the SOLE reason a block is a control point: if from has only
|
||
// post-dominating successors it controls nothing.
|
||
}
|
||
},
|
||
);
|
||
});
|
||
});
|
||
|
||
describe('computeControlDependence — maxEdges materialization ceiling (#2188)', () => {
|
||
// A switch dispatch yields three deduped CDG edges (1->2/3/4, all 'T').
|
||
const switchCfg = (): FunctionCfg =>
|
||
mkCfg(6, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'switch-case'],
|
||
[1, 3, 'switch-case'],
|
||
[1, 4, 'switch-case'],
|
||
[2, 5, 'break'],
|
||
[3, 5, 'break'],
|
||
[4, 5, 'break'],
|
||
]);
|
||
|
||
it('stops at the ceiling and reports truncated (deterministic prefix)', () => {
|
||
const r = computeControlDependence(switchCfg(), undefined, 2);
|
||
expect(r.truncated).toBe(true);
|
||
expect(r.edges).toHaveLength(2);
|
||
// the prefix is still sorted/deduped, a valid subset of the full result
|
||
for (const e of r.edges) expect(['T', 'F']).toContain(e.label);
|
||
});
|
||
|
||
it('maxEdges of 0 means unbounded (full result, not truncated)', () => {
|
||
const r = computeControlDependence(switchCfg(), undefined, 0);
|
||
expect(r.truncated).toBe(false);
|
||
expect(serAll(r.edges).sort()).toEqual(['1->2:T', '1->3:T', '1->4:T']);
|
||
});
|
||
|
||
it('a ceiling at/above the true count is not truncated', () => {
|
||
const r = computeControlDependence(switchCfg(), undefined, 3);
|
||
expect(r.truncated).toBe(false);
|
||
expect(r.edges).toHaveLength(3);
|
||
});
|
||
});
|
||
|
||
describe('computeControlDependence — reverse-DF formulation regressions (#2195)', () => {
|
||
// The Ferrante up-walk was replaced by the reverse-CFG post-dominance frontier
|
||
// (Cytron/CFRWZ 1991; LLVM ReverseIDFCalculator / Joern CdgPass). These pin the
|
||
// three invariants the rewrite must preserve: multi-label-per-pair, the
|
||
// self-edge / NO_IPDOM seed guard, and linear (not quadratic) scaling.
|
||
|
||
it('keeps BOTH senses when one controller reaches a dependent via opposite arms', () => {
|
||
// Minimised goto-cycle shape: A (block 1) reaches X (block 2) by BOTH its true
|
||
// arm and its false arm, with a third escape (1→3) so X does NOT post-dominate
|
||
// A (else it would be a plain diamond join controlling nothing). The PDF must
|
||
// union the label SET — the old (a, cur, label) dedup kept both rows — not
|
||
// collapse the pair to a single sense.
|
||
const cfg = mkCfg(
|
||
4,
|
||
[
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'],
|
||
[1, 2, 'cond-false'],
|
||
[1, 3, 'seq'], // escape so X(2) is not a post-dominator of A(1)
|
||
[2, 3, 'seq'],
|
||
],
|
||
{ entry: 0, exit: 3 },
|
||
);
|
||
const { edges } = computeControlDependence(cfg);
|
||
expect(serAll(edges).sort()).toEqual(['1->2:F', '1->2:T']);
|
||
});
|
||
|
||
it('a literal self-edge never invents a self control-dependence (PDF_local a!==x guard)', () => {
|
||
// Standard while loop (header 1, body 2) PLUS a spurious LITERAL self-edge
|
||
// 1→1. The header legitimately depends on itself via the body back-edge
|
||
// (1->1:T is inherited through PDF_up), but the literal self in-edge must
|
||
// contribute NOTHING: without the `a !== x` PDF_local guard it would seed a
|
||
// bogus 1->1:F (the loop-back complement of the header's true arm).
|
||
const cfg = mkCfg(4, [
|
||
[0, 1, 'seq'],
|
||
[1, 2, 'cond-true'],
|
||
[2, 1, 'loop-back'],
|
||
[1, 1, 'loop-back'], // spurious literal self-edge — must be ignored
|
||
[1, 3, 'cond-false'],
|
||
]);
|
||
const { edges } = computeControlDependence(cfg);
|
||
expect(serAll(edges).sort()).toEqual(['1->1:T', '1->2:T']);
|
||
expect(edges.some((e) => ser(e) === '1->1:F')).toBe(false);
|
||
});
|
||
|
||
it('scales linearly on a fan-into-chain (perf tripwire: was a Θ(N²) up-walk)', () => {
|
||
// fanChain(N): block 0 fans one edge to every node of a length-(N-2) spine
|
||
// whose ipdom chain tops out at EXIT. The old Ferrante up-walk re-climbed the
|
||
// shared spine once per fan edge → Θ(N²) (~7.1s at N=16k); the reverse-DF form
|
||
// is O(N+E+output) (~13ms). Coarse wall-clock tripwire, not a microbenchmark:
|
||
// a revert to quadratic blows past the ceiling by >5×. post-dom is built
|
||
// outside the timed region so this isolates the rewritten function.
|
||
const N = 16000;
|
||
const M = N - 2; // chain blocks 1..M; block 0 = fan source; block N-1 = EXIT
|
||
const exit = N - 1;
|
||
const edges: [number, number, CfgEdgeKind][] = [];
|
||
for (let i = 1; i <= M; i++) {
|
||
edges.push([0, i, 'switch-case']); // fan: 0 → every chain node
|
||
edges.push([i, i === M ? exit : i + 1, 'seq']); // spine: i → i+1 (→ EXIT)
|
||
}
|
||
const cfg = mkCfg(N, edges, { entry: 0, exit });
|
||
const postDom = computePostDominators(cfg); // built OUTSIDE the timed region
|
||
const t0 = performance.now();
|
||
const { edges: cdg } = computeControlDependence(cfg, postDom);
|
||
const ms = performance.now() - t0;
|
||
// Block 0 controls every chain node EXCEPT the last (M): the sole edge into
|
||
// EXIT is M→exit, so M post-dominates the whole fan (ipdom[0]===M) and is
|
||
// controlled by nothing. The other M-1 spine nodes are each 0-controlled.
|
||
// Assert IDENTITY too (not just count) so a fast-but-wrong reimplementation
|
||
// emitting M-1 mis-attributed edges can't pass on length alone.
|
||
expect(cdg).toHaveLength(M - 1);
|
||
expect(cdg.every((e) => e.controllerBlock === 0)).toBe(true);
|
||
expect(cdg.every((e) => e.dependentBlock >= 1 && e.dependentBlock < M)).toBe(true);
|
||
expect(new Set(cdg.map((e) => e.dependentBlock)).size).toBe(M - 1); // all distinct
|
||
expect(ms).toBeLessThan(1000);
|
||
});
|
||
});
|
||
|
||
describe('#2188 F1 — branch-label correctness on the REAL TS visitor (regression)', () => {
|
||
// The label is the AC3 "under what condition does X run?" answer. The bug:
|
||
// branchSense inferred it from the edge KIND alone, but the M1 visitor wires a
|
||
// condition's fall-through FALSE arm as `seq`/`loop-back` (not `cond-false`),
|
||
// so guard clauses / loop break got 'T' instead of 'F'. These tests run the
|
||
// REAL parser+visitor (the hand-built tests above used a fictional `cond-false`
|
||
// edge and could not catch the regression).
|
||
const tsVisitor = getProvider(SupportedLanguages.TypeScript).cfgVisitor;
|
||
const parser = new Parser();
|
||
if (tsVisitor) parser.setLanguage(TypeScript.typescript);
|
||
|
||
function cdgOf(code: string): { cfg: FunctionCfg; edges: readonly ControlDepEdge[] } {
|
||
if (!tsVisitor) throw new Error('no cfgVisitor');
|
||
const cfgs = collectFunctionCfgs(parser.parse(code).rootNode, tsVisitor, 't.ts').cfgs;
|
||
expect(cfgs.length).toBe(1);
|
||
return { cfg: cfgs[0], edges: computeControlDependence(cfgs[0]).edges };
|
||
}
|
||
const labelOf = (
|
||
edges: readonly ControlDepEdge[],
|
||
controller: number,
|
||
dependent: number,
|
||
): CdgLabel | undefined =>
|
||
edges.find((e) => e.controllerBlock === controller && e.dependentBlock === dependent)?.label;
|
||
|
||
it("guard clause: post-guard body runs on the guard's FALSE (seq) arm → 'F'", () => {
|
||
const { cfg, edges } = cdgOf(`function f(x){ if (!ok(x)) return; use(x); }`);
|
||
const guard = cfg.blocks.find((b) => b.text.includes('ok(x)'))!;
|
||
const use = cfg.blocks.find((b) => b.text.includes('use(x)'))!;
|
||
expect(labelOf(edges, guard.index, use.index)).toBe('F');
|
||
});
|
||
|
||
it("do/while: body runs on the bottom-test's TRUE (loop-back) arm → 'T'", () => {
|
||
const { cfg, edges } = cdgOf(`function f(){ do { body(); } while (c()); }`);
|
||
const test = cfg.blocks.find((b) => b.text.includes('c()'))!;
|
||
const body = cfg.blocks.find((b) => b.text.includes('body()'))!;
|
||
expect(labelOf(edges, test.index, body.index)).toBe('T');
|
||
});
|
||
|
||
it("while+break: post-break tail runs on the if's FALSE (seq) arm → 'F'", () => {
|
||
const { cfg, edges } = cdgOf(`function f(o,i){ while (o) { if (i) break; tail(); } }`);
|
||
const ifCond = cfg.blocks.find((b) => b.text === '(i)')!;
|
||
const tail = cfg.blocks.find((b) => b.text.includes('tail()'))!;
|
||
expect(labelOf(edges, ifCond.index, tail.index)).toBe('F');
|
||
});
|
||
|
||
it("if/else still labels both arms correctly (no regression) → then 'T', else 'F'", () => {
|
||
const { cfg, edges } = cdgOf(`function f(x){ if (x) { a(); } else { b(); } }`);
|
||
const cond = cfg.blocks.find((b) => b.text === '(x)')!;
|
||
const thenB = cfg.blocks.find((b) => b.text.includes('a()'))!;
|
||
const elseB = cfg.blocks.find((b) => b.text.includes('b()'))!;
|
||
expect(labelOf(edges, cond.index, thenB.index)).toBe('T');
|
||
expect(labelOf(edges, cond.index, elseB.index)).toBe('F');
|
||
});
|
||
});
|