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] 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() {