From 2071cadf1e6f825ed0465682f6ea58b3fed1263a Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 14:09:21 +0000 Subject: [PATCH] feat(cfg): Dart CFG visitor + def/use harvest (#2195 U14) Add createDartCfgVisitor + dart-harvest (vendored tree-sitter-dart): if/else, C-for/for-in/while/do-while, switch (empty-case fallthrough + explicit continue-label) + switch_expression, try/on/catch/finally + rethrow + assert (throw edges), return/break/continue/throw, labeled loops, arrow bodies, closures. Dart splits a function into sibling signature + function_body nodes, so the body (or function_expression) is the CFG-bearing node. Wire into dartProvider. Every literal validated against the vendored grammar via the probe (only constant_pattern exists; removed speculative relational/logical pattern names). while (true) keeps EXIT reverse-reachable (production CDG probe: 3 edges). 34 real-parser tests; comprehensive sweep green (605). Gaps: labeled-loop grammar quirk (read via ERROR sibling), async straight-line, value-position if/switch inline. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ingestion/cfg/visitors/dart-harvest.ts | 557 +++++++++++ .../src/core/ingestion/cfg/visitors/dart.ts | 927 ++++++++++++++++++ gitnexus/src/core/ingestion/languages/dart.ts | 2 + .../cfg/fixtures/dart-hazards.dart | 134 +++ gitnexus/test/unit/cfg/dart-visitor.test.ts | 424 ++++++++ 5 files changed, 2044 insertions(+) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/dart.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/dart-hazards.dart create mode 100644 gitnexus/test/unit/cfg/dart-visitor.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts new file mode 100644 index 000000000..ca4bb366d --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts @@ -0,0 +1,557 @@ +/** + * Dart def/use harvester (#2195) — the Dart analogue of + * {@link import('./kotlin-harvest.js').KotlinHarvester} and the Swift / Python / + * Rust harvesters. Like them it harvests NO call-site `sites[]` (the call-site + * taint substrate is a later step): it emits only the per-function binding table + * ({@link BindingEntry}[]) plus {@link StatementFacts} (defs / uses / mayDefs) via + * a local {@link FactAccumulator} with no site machinery, so the produced facts + * never carry a `sites` key. + * + * Runs in the parse worker next to the Dart CFG visitor. Output is the binding + * table the {@link import('../cfg-builder.js').CfgBuilder} stamps onto the CFG, + * plus the per-block def/use facts the reaching-defs / CDG solvers consume. + * + * Every node-type literal below was grammar-validated against the VENDORED + * tree-sitter-dart via the introspection probe before use (mandatory pre-step — + * the grammar-literal CI gate maps `dart-harvest.ts → Dart` and fails on a wrong + * literal). Dart's grammar splits a function into SIBLING nodes — a + * `function_signature` / `method_signature` / getter/setter signature followed by + * a sibling `function_body` (the body, NOT a child of the signature) — so this + * harvester takes the `function_body` (or a closure's `function_expression`) as + * the function node and reaches the signature via the previous sibling. + * + * Dart shapes pre-empted (verified by a real parse): + * - parameters: `function_signature`/`method_signature`/`setter_signature` own a + * `formal_parameter_list` → `formal_parameter` (each `name:identifier`). A + * closure (`function_expression`) owns `parameters:formal_parameter_list`. + * - `local_variable_declaration` → `initialized_variable_definition` + * (`name:identifier` `= value`). The declaration kind keyword is `inferred_type` + * (`var`), `final_builtin` (`final`), a `type_identifier`/`void_type` (typed), + * or `late` (anon). A bare `var e;` with no initializer still binds the name. + * - `for_loop_parts` — C-style (`init:local_variable_declaration`, + * `condition:`, `update:`) OR for-in (`inferred_type`? `name:identifier` `in` + * `value:` — or a bare `identifier` `in` `value:` over an existing variable). + * - `catch_clause` → `catch_parameters` (`(e)` or `(e, st)` — both bound). + * - reads: `identifier`, `selector` (`.name` / `(...args)` member/call chain), + * `assignment_expression` (`left:assignable_expression` `operator:` `right:`), + * `if_null_expression` (`a ?? b`), `conditional_expression` (`c ? a : b`), + * logical `&&` / `||` (`logical_and_expression` / `logical_or_expression`). + * + * TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Kotlin / Swift / Rust + * harvesters): the CFG walk is NOT source-order (`do … while` builds the condition + * after the body), so resolving names against a scope stack populated *during* the + * walk would mis-resolve. Phase 1 pre-scans the whole function subtree once, + * declaring every bound name into ONE function table; phase 2 resolves defs/uses + * against that finished table from any walk order. Dart DOES have block scope + + * shadowing, but a single function table is the documented v1 simplification used + * by the Kotlin / Swift / Python / Rust harvesters — distinct shadowing + * redeclarations of the same name collapse onto one binding (an over-approximation + * that can falsely kill across a shadow, the sound direction for taint). + * + * v1 def-semantics scope: + * - `initialized_variable_definition` (`var`/`final`/typed `PAT = …`) — the + * `name:identifier` is a def; the value is walked for uses. A bare declaration + * with no initializer still binds the name (Dart locals are in scope from the + * declaration; an uninitialized read is a compile error, so binding is safe). + * - `assignment_expression` plain `=` — a plain-identifier lvalue is a def; a + * member / subscript target (`this.x = …`, `a[i] = …`) is NOT a scalar def + * (its root is a use). A compound `+=`/`-=`/… target def-AND-uses the lvalue. + * - `postfix_expression` / `prefix_expression` update (`i++` / `--i`) def-and-use. + * - `for (var e in xs)` — the loop pattern name is a def, the collection a use. + * - `catch (e, st)` — both error binders bind. + * - parameters (incl. closure params) are `param`-kind defs. + * EXCLUDED, deliberately (TypeScript-CFA precedent): member / subscript writes + * (`obj.f = …`, `a[i] = …`) are NOT scalar defs — their root identifiers are uses + * only. Nested-function bodies (`function_expression`) are opaque in BOTH directions. + * + * MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right + * operand of `&&` / `||` short-circuit, the `??` right operand, a conditional + * (`? :`) arm, and a `switch`-expression / case-pattern test — is a may-def (gen + * WITHOUT kill), so the not-taken path's prior def is not falsely killed. + * + * Identifiers with no in-function declaration (top-level functions, types, + * fields) resolve to a SYNTHETIC module-level binding (`name@module`), applied + * identically by def and use harvesting. + * + * NOTE: nothing serialized here may carry a field named `nodeId` — the durable + * parsedfile-store reviver dedups objects keyed on that field name. + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import type { BindingEntry, StatementFacts } from '../types.js'; + +/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */ +const NESTED_FUNCTION_TYPES = new Set(['function_expression', 'function_body']); + +const COMMENT_TYPES = new Set(['comment', 'documentation_comment']); + +const FUNCTION_VALUE_TYPES = new Set(['function_expression']); + +/** + * Minimal ordered, deduplicating def/use collector for one statement record. + * Deliberately NOT the shared {@link import('./call-site-harvest.js') + * CallSiteFactAccumulator} — this unit harvests NO call sites (taint substrate is + * a later step), so a local accumulator with only the def/use/may-def machinery + * keeps `dart-harvest.ts` free of site logic and guarantees the emitted facts + * carry no `sites` key (mirrors the Kotlin / Swift / Python / Rust harvesters). + */ +class FactAccumulator { + private readonly defs: number[] = []; + private readonly uses: number[] = []; + private readonly mayDefs: number[] = []; + private readonly defSeen = new Set(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + + constructor(private readonly line: number) {} + + addDef(idx: number): void { + if (this.defSeen.has(idx)) return; + this.defSeen.add(idx); + this.defs.push(idx); + } + + /** A def that may not execute (conditional context) — gen without kill. */ + addMayDef(idx: number): void { + if (this.mayDefSeen.has(idx)) return; + this.mayDefSeen.add(idx); + this.mayDefs.push(idx); + } + + addUse(idx: number): void { + if (this.useSeen.has(idx)) return; + this.useSeen.add(idx); + this.uses.push(idx); + } + + defCount(): number { + return this.defs.length + this.mayDefs.length; + } + + finish(): StatementFacts { + return { + line: this.line, + defs: this.defs, + uses: this.uses, + // Stay absent when empty — keeps the serialized side-channel payload lean. + ...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}), + }; + } +} + +export class DartHarvester { + private readonly bindings: BindingEntry[] = []; + /** Single function-scope name → binding index (v1: no block scope). */ + private readonly table = new Map(); + private readonly synthetic = new Map(); + private readonly fnId: number; + /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */ + private conditionalDepth = 0; + + /** + * @param fnNode The function-bearing node: a `function_body` (whose previous + * sibling is the signature carrying the params) or a `function_expression` + * (a closure, carrying its own `parameters`). + * @param signature The previous-sibling signature for a `function_body`, or + * undefined for a `function_expression` (which carries params directly). + */ + constructor( + private readonly fnNode: SyntaxNode, + private readonly signature: SyntaxNode | undefined, + ) { + this.fnId = fnNode.id; + this.declareParams(); + const body = this.bodyOf(fnNode); + if (body) this.prescan(body); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + bindingTable(): readonly BindingEntry[] { + return this.bindings; + } + + /** The body subtree to pre-scan: a `function_body`'s `block`/expr, or a closure's body. */ + private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined { + if (fnNode.type === 'function_expression') { + return fnNode.childForFieldName('body') ?? undefined; + } + // `function_body` — its child `block` or arrow expression. + return fnNode.namedChildren.find((c) => !COMMENT_TYPES.has(c.type)); + } + + // ── parameters ──────────────────────────────────────────────────────────── + + /** The `formal_parameter_list` owning this function's params. */ + private paramList(): SyntaxNode | undefined { + if (this.fnNode.type === 'function_expression') { + return this.fnNode.childForFieldName('parameters') ?? undefined; + } + if (!this.signature) return undefined; + // A `method_signature` wraps a `function_signature` / getter / setter that + // carries the actual `formal_parameter_list`; unwrap one level first. + let sig = this.signature; + if (sig.type === 'method_signature') { + const inner = sig.namedChildren.find( + (c) => + c.type === 'function_signature' || + c.type === 'setter_signature' || + c.type === 'getter_signature' || + c.type === 'constructor_signature' || + c.type === 'factory_constructor_signature', + ); + if (inner) sig = inner; + } + return sig.namedChildren.find((c) => c.type === 'formal_parameter_list'); + } + + /** Every `formal_parameter`'s bound name node. */ + private paramNames(): SyntaxNode[] { + const list = this.paramList(); + if (!list) return []; + const names: SyntaxNode[] = []; + for (const p of list.namedChildren) { + if (p.type !== 'formal_parameter') continue; + const name = p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier'); + if (name) names.push(name); + } + return names; + } + + // ── phase 1: declaration pre-scan ──────────────────────────────────────── + + private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void { + const name = nameNode.text; + if (!name || name === '_' || this.table.has(name)) return; + this.table.set(name, this.bindings.length); + this.bindings.push({ + name, + declLine: nameNode.startPosition.row + 1, + declColumn: nameNode.startPosition.column, + kind, + }); + } + + private declareParams(): void { + for (const name of this.paramNames()) this.declare(name, 'param'); + } + + /** + * Pre-scan the function body once, declaring every bound name. Recurses into + * compound expressions but NOT into nested function/closure bodies (opaque). + */ + private prescan(node: SyntaxNode): void { + const t = node.type; + if (FUNCTION_VALUE_TYPES.has(t) && node.id !== this.fnId) return; + + switch (t) { + case 'initialized_variable_definition': + this.declareInitializedVar(node, 'let'); + break; + case 'for_loop_parts': + this.declareForParts(node); + break; + case 'catch_parameters': + this.declareCatchParams(node); + break; + default: + break; + } + + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.prescan(c); + } + } + + /** Declare the `name:identifier` of an `initialized_variable_definition`. */ + private declareInitializedVar(node: SyntaxNode, kind: BindingEntry['kind']): void { + const name = node.childForFieldName('name'); + if (name) this.declare(name, kind); + } + + /** + * Declare a `for`'s loop variable: a C-style `init:local_variable_declaration` + * is handled by its own `initialized_variable_definition` recursion; a for-in + * binds the `name:identifier` after the optional `inferred_type`/type. A for-in + * over an existing variable (`for (e in xs)`) has no declaration — its bare + * `identifier` is a use (an assignment target), not a new binding. + */ + private declareForParts(node: SyntaxNode): void { + // for-in declares the loop var only when a binder keyword/type precedes it. + if (!this.isForIn(node)) return; + if (!this.forInDeclares(node)) return; + const name = node.childForFieldName('name'); + if (name) this.declare(name, 'let'); + } + + /** A `for_loop_parts` is for-in iff it has an `in` keyword child + a `value` field. */ + private isForIn(node: SyntaxNode): boolean { + return node.children.some((c) => c.type === 'in'); + } + + /** A for-in declares a fresh loop var iff a binder keyword/type precedes the name. */ + private forInDeclares(node: SyntaxNode): boolean { + return node.namedChildren.some( + (c) => + c.type === 'inferred_type' || + c.type === 'final_builtin' || + c.type === 'type_identifier' || + c.type === 'void_type', + ); + } + + /** Declare a `catch (e[, st])` error name(s). */ + private declareCatchParams(node: SyntaxNode): void { + for (const id of node.namedChildren) { + if (id.type === 'identifier') this.declare(id, 'catch'); + } + } + + // ── phase 2: per-statement fact extraction ─────────────────────────────── + + /** Def/use facts for one statement (or construct-header expression) node. */ + facts(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.walkValue(node, acc); + return acc.finish(); + } + + /** Facts for an expression whose WHOLE evaluation is conditional (case tests). */ + factsConditional(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.conditional(() => this.walkValue(node, acc)); + return acc.finish(); + } + + /** + * 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 + * defs/uses (the init `local_variable_declaration` defines, the condition reads, + * the update def-and-uses). + */ + forHeadFacts(parts: SyntaxNode | undefined): StatementFacts | undefined { + const line = (parts ?? this.fnNode).startPosition.row + 1; + const acc = new FactAccumulator(line); + if (!parts) return undefined; + if (this.isForIn(parts)) { + const value = parts.childForFieldName('value'); + if (value) this.walkValue(value, acc); + // The loop var: a fresh `for (var e in xs)` binder is a def; a + // `for (e in xs)` over an existing var also writes it each iteration (a + // def). Either way the `name:identifier` is a def of the loop variable. + const name = parts.childForFieldName('name'); + if (name) this.def(name, acc); + } else { + // C-style: walk init / condition / update. + const init = parts.childForFieldName('init'); + const cond = parts.childForFieldName('condition'); + const update = parts.childForFieldName('update'); + if (init) this.walkValue(init, acc); + if (cond) this.walkValue(cond, acc); + if (update) this.walkValue(update, acc); + } + return acc.finish(); + } + + /** ENTRY-block facts for the parameters (defs only). */ + paramFacts(): StatementFacts | undefined { + const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); + for (const name of this.paramNames()) this.def(name, acc); + return acc.defCount() ? acc.finish() : undefined; + } + + /** Def fact(s) for a `catch (e[, st])` — prepend to the handler entry block. */ + catchParamFacts(catchParams: SyntaxNode | undefined): StatementFacts | undefined { + if (!catchParams) return undefined; + const acc = new FactAccumulator(catchParams.startPosition.row + 1); + for (const id of catchParams.namedChildren) { + if (id.type === 'identifier') this.def(id, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + + private resolve(nameNode: SyntaxNode): number { + const name = nameNode.text; + const idx = this.table.get(name); + if (idx !== undefined) return idx; + let syn = this.synthetic.get(name); + if (syn === undefined) { + syn = this.bindings.length; + this.synthetic.set(name, syn); + this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true }); + } + return syn; + } + + private def(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (nameNode.text === '_') return; // blank target defines nothing + if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode)); + else acc.addDef(this.resolve(nameNode)); + } + + private use(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (nameNode.text === '_') return; + acc.addUse(this.resolve(nameNode)); + } + + /** Run `fn` with defs demoted to may-defs (conditionally-evaluated context). */ + private conditional(fn: () => void): void { + this.conditionalDepth++; + try { + fn(); + } finally { + this.conditionalDepth--; + } + } + + /** Value-position walk: collect uses; route def positions to the pattern handler. */ + private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + const t = node.type; + if (FUNCTION_VALUE_TYPES.has(t) && node.id !== this.fnId) return; // opaque closure + + switch (t) { + case 'identifier': + this.use(node, acc); + return; + case 'initialized_variable_definition': { + const value = node.childForFieldName('value'); + if (value) this.walkValue(value, acc); + const name = node.childForFieldName('name'); + if (name) this.def(name, acc); + return; + } + case 'assignment_expression': { + const lvalue = node.childForFieldName('left'); + const op = node.childForFieldName('operator'); + const value = node.childForFieldName('right'); + if (value) this.walkValue(value, acc); + // A `right` field can repeat (an identifier + trailing selectors): walk + // every named child after the operator that isn't the lvalue. + for (const c of node.namedChildren) { + if (c === lvalue) continue; + if (c.type === 'assignable_expression') continue; + if (c === value) continue; + this.walkValue(c, acc); + } + if (lvalue) { + const scalar = this.scalarAssignTarget(lvalue); + if (scalar) { + this.def(scalar, acc); + if (op && op.text !== '=') this.use(scalar, acc); // compound assign reads too + } else { + // `this.x = …`, `a[i] = …` — a member / subscript write is NOT a + // scalar def; walk the lvalue so its root identifier is a use. + this.walkValue(lvalue, acc); + } + } + return; + } + case 'postfix_expression': + case 'unary_expression': { + // `i++` (`postfix_expression`) / `++i` (`unary_expression` with an + // `increment_operator`) — the assignable operand is def-and-use. A + // `unary_expression` with no `increment_operator` (`!x`, `-x`, `await e`) + // is a pure read and falls through to the generic walk below. + const isUpdate = + t === 'postfix_expression' || + node.namedChildren.some((c) => c.type === 'increment_operator'); + const operand = isUpdate + ? node.namedChildren.find((c) => c.type === 'assignable_expression') + : undefined; + if (operand) { + const scalar = this.scalarAssignTarget(operand); + if (scalar) { + this.use(scalar, acc); + this.def(scalar, acc); + } else { + // `obj.x++` / `a[i]++` — member/subscript update, not a scalar def. + this.walkValue(operand, acc); + } + } else { + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.walkValue(c, acc); + } + } + return; + } + case 'selector': { + // `.name` / `(...args)` — a member-access suffix name is not a scalar + // binding; walk the argument part for uses but skip the bare property id. + for (const c of node.namedChildren) { + if (c.type === 'unconditional_assignable_selector' || c.type === 'conditional_assignable_selector') { + continue; // `.name` — property name is not a use + } + this.walkValue(c, acc); + } + return; + } + case 'logical_and_expression': + case 'logical_or_expression': { + // `a && b` / `a || b` — the right operand is conditionally evaluated. + const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type)); + if (operands.length > 0) this.walkValue(operands[0], acc); + for (let i = 1; i < operands.length; i++) { + const rhs = operands[i]; + this.conditional(() => this.walkValue(rhs, acc)); + } + return; + } + case 'if_null_expression': { + // `a ?? b` — the right operand only evaluates when the left is null. + const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type)); + if (operands.length > 0) this.walkValue(operands[0], acc); + for (let i = 1; i < operands.length; i++) { + const rhs = operands[i]; + this.conditional(() => this.walkValue(rhs, acc)); + } + return; + } + case 'conditional_expression': { + // `c ? a : b` — the condition runs always; both arms are conditional. + const operands = node.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type)); + if (operands.length > 0) this.walkValue(operands[0], acc); + for (let i = 1; i < operands.length; i++) { + const arm = operands[i]; + this.conditional(() => this.walkValue(arm, acc)); + } + return; + } + case 'inferred_type': + case 'final_builtin': + case 'type_identifier': + case 'void_type': + // Binding keyword / type position — no scalar value uses. + return; + default: + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.walkValue(c, acc); + } + } + } + + /** + * The bare `identifier` of an `assignable_expression` lvalue WHEN it is a + * scalar target (`x = …`), or undefined when it is a member / subscript write + * (`obj.x = …`, `a[i] = …`) — those carry a trailing + * `unconditional_assignable_selector` / `conditional_assignable_selector` / + * `index_selector` and are NOT scalar defs (their root identifier is a use). + */ + private scalarAssignTarget(node: SyntaxNode): SyntaxNode | undefined { + // Unwrap nested `assignable_expression` wrappers (defensive). + let n = node; + let hops = 4; + while (n.type === 'assignable_expression' && hops-- > 0) { + const named = n.namedChildren.filter((c) => !COMMENT_TYPES.has(c.type)); + // A single bare identifier child ⇒ scalar target; any trailing selector ⇒ + // member/subscript write (not scalar). + if (named.length === 1 && named[0].type === 'identifier') return named[0]; + if (named.length === 1 && named[0].type === 'assignable_expression') { + n = named[0]; + continue; + } + return undefined; // identifier + selector(s) — member/subscript write + } + return n.type === 'identifier' ? n : undefined; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/dart.ts b/gitnexus/src/core/ingestion/cfg/visitors/dart.ts new file mode 100644 index 000000000..af3b16812 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/dart.ts @@ -0,0 +1,927 @@ +/** + * Dart CfgVisitor (#2195) — the VENDORED-GRAMMAR, SPLIT-FUNCTION brace-family + * CFG target. Dart's tree-sitter grammar is unusual: a function is NOT a single + * wrapping node — a `function_signature` / `method_signature` / `getter_signature` + * / `setter_signature` is followed by a SIBLING `function_body` (the body is a + * sibling of the signature, under `program` / `class_body`, NOT a child of the + * declaration). This visitor therefore treats the `function_body` itself (and a + * closure's `function_expression`) as the CFG-bearing node, reaching the params + * via the body's previous-sibling signature — exactly the seam the existing + * `dartEnclosingFunctionFinder` uses. Every node type and field literal below was + * grammar-validated against the vendored tree-sitter-dart via the introspection + * probe before use (mandatory pre-step — the grammar-literal CI gate maps + * `dart.ts → Dart` and fails on a wrong literal). + * + * The visitor drives the language-agnostic {@link CfgBuilder} to produce a + * serializable {@link FunctionCfg} plus a def/use harvest ({@link DartHarvester}) + * for the reaching-defs / CDG solvers, structured like the sibling visitors — a + * `visit_` dispatch over the control-flow taxonomy driving a + * per-function {@link ControlFlowContext} for labeled break/continue and the + * try/catch/finally completion chain (Dart shares JVM-style `finally` semantics). + * + * Dart shapes pre-empted (verified by a real parse): + * - `function_body` — `{ block }` OR an arrow body `=> expr ;` (no `block` + * wrapper). A `getter_signature` / `setter_signature` body is the same + * `function_body` shape. + * - `function_expression` (a closure) — fields `parameters:formal_parameter_list` + * and `body:function_expression_body` (itself a `{ block }` or `=> expr`). + * - `block` — `{ statement* }`; statements are its named children. + * - `if_statement` — `if ( COND ) consequence:STMT [ else alternative:STMT ]`. + * The condition is the named child between `(` and `)`; the consequence/ + * alternative are a `block` (braced) or a bare statement (`if (c) a();`). An + * `else if` is the nested `if_statement` in the `alternative` field. + * - `for_statement` — `for ( for_loop_parts ) body:STMT`. `for_loop_parts` is + * C-style (`init:` `condition:` `;` `update:`) OR for-in (`inferred_type`? + * `name:identifier` `in` `value:` — or a bare `identifier in value` over an + * existing variable). + * - `while_statement` — `while condition:parenthesized_expression body:STMT`. + * - `do_statement` — BOTTOM-TEST: `do body:STMT while condition:… ;`. + * - `switch_statement` — `switch condition:parenthesized_expression + * body:switch_block`. A `switch_block` holds `switch_statement_case` + * (`case_builtin constant_pattern : STMT*` — an EMPTY case with no statements + * falls through to the next; an optional leading `label` names it) and one + * `switch_statement_default` (`default : STMT*`). Dart cases do NOT fall + * through implicitly EXCEPT an empty case; an explicit `continue LABEL;` jumps + * to the labeled case. `switch_expression` (`{ switch_expression_case* }`, + * `pat => expr`) never falls through. + * - `try_statement` — `try body:block` then `on type? catch_clause? block` + * groups (the `on Type` / `catch (e[, st])` parts are bare children: + * `on` keyword, `type_identifier`, `catch_clause` → `catch_parameters`, and the + * handler `block`) plus an optional `finally_clause` (`finally block`). + * - jumps: `return_statement` (`return [expr] ;`), `break_statement` + * (`break [label] ;`), `continue_statement` (`continue [label] ;`), + * `throw_expression` (in an `expression_statement`), `rethrow_expression` + * (`rethrow ;`), `assert_statement` (`assert ( … ) ;` — may throw). + * - a labeled LOOP (`outer: for …`) parses as a stray `ERROR [identifier :]` + * SIBLING immediately before the `for_statement` (tree-sitter-dart does not + * model a statement label outside a switch); the visitor reads that ERROR + * sibling as a pending label, so `break outer` still resolves. + * + * Edge-kind contract (matches the existing visitors — RD/CDG consume these): + * - if / else → `cond-true` / `cond-false` + * - `for` / `while` → `cond-true` / `loop-back` / `cond-false` + * - `do … while` → bottom-test: body runs first, condition `loop-back` (true) + * / `cond-false` (exit) + * - `switch` dispatch → `switch-case`; an EMPTY case spills to the next case via + * a `fallthrough` edge, and an explicit `continue LABEL;` is a `fallthrough` + * to the labeled case. A non-empty case rejoins after the switch (no implicit + * fallthrough). + * - try/on/catch → `throw` (every protected-region block → the first handler); a + * `finally` runs on BOTH normal and exception exit, so a `return`/`break`/ + * `continue` crossing it threads through (`finally-*` completion edges). A + * `rethrow` re-routes to the next-outer handler / EXIT. + * - return / throw / break / continue / rethrow → the matching terminator kind; + * a labeled `break outer` / `continue outer` targets the labeled loop frame + * - straight-line → `seq` + * + * Dart-specific modeling decisions (documented approximations): + * - `while (true) {}` / `for (;;) {}` may never terminate; like the C-family / + * Go / Rust / Swift / Kotlin visitors, this visitor ALWAYS emits the + * structural `header → loopExit` `cond-false` escape edge so EXIT stays + * reverse-reachable and the post-dominator / CDG pass is not silently skipped + * for the function. This is the single highest-risk correctness property. + * - try/on/catch: conservative exceptional flow — EVERY block in the protected + * region edges to the first handler (an exception may fire mid-block), + * matching the Java / C# / Swift over-approximation. A `throw` / `rethrow` / + * `assert` with no enclosing handler routes to EXIT (the function propagates + * the error to its caller). + * - 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). + * + * Known limitations: + * - block-scope shadowing in the harvest is flattened to one function table (see + * dart-harvest.ts) — a documented v1 over-approximation. + * - `async` / `await` / `async*` / `sync*`: suspension/yield points are normal + * straight-line flow (no scheduler edges). A closure passed to `Future`/stream + * APIs gets its own CFG like any closure. + * - a generative/redirecting constructor's `: initializer` list and a + * `factory` constructor body are NOT modeled as a distinct function node in + * this v1 set (the `function_body` after a `constructor_signature` IS modeled; + * the initializer list runs straight-line into it — documented gap). + * + * Returns `undefined` (never throws) for an AST shape it cannot model, so a + * malformed function never drops the whole file's CFG group (R4). + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { + ControlFlowContext, + drainFinalizerPending, + wireJumpThroughFinalizers, +} from '../control-flow-context.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg } from '../types.js'; +import { DartHarvester } from './dart-harvest.js'; + +/** Signature node types whose SIBLING `function_body` owns a CFG-bearing body. */ +const DART_SIGNATURE_TYPES = new Set([ + 'function_signature', + 'method_signature', + 'getter_signature', + 'setter_signature', + 'constructor_signature', + 'factory_constructor_signature', +]); + +/** Statement node types that break a basic block (everything else coalesces). */ +const CONTROL_FLOW_TYPES = new Set([ + 'if_statement', + 'for_statement', + 'while_statement', + 'do_statement', + 'switch_statement', + 'try_statement', + 'return_statement', + 'break_statement', + 'continue_statement', + 'assert_statement', +]); + +/** Comment node types tree-sitter-dart surfaces. */ +const COMMENT_TYPES = new Set(['comment', 'documentation_comment']); + +const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1; +const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1; + +const isComment = (n: SyntaxNode): boolean => COMMENT_TYPES.has(n.type); + +/** Whether an `expression_statement` is a bare `throw …;`. */ +const isThrowStatement = (n: SyntaxNode): boolean => + n.type === 'expression_statement' && + n.namedChildren.some((c) => c.type === 'throw_expression'); + +/** Whether an `expression_statement` is a bare `rethrow;`. */ +const isRethrowStatement = (n: SyntaxNode): boolean => + n.type === 'expression_statement' && + n.namedChildren.some((c) => c.type === 'rethrow_expression'); + +/** A statement sequence that produced no blocks (empty body) is "transparent". */ +type SeqResult = TraversalResult | null; + +/** + * Per-function Dart walk state. One instance per function so the + * {@link ControlFlowContext}, exception-handler stack, and labeled-frame + * bookkeeping are scoped to that function and never leak across functions. + */ +class DartCfgWalk { + private readonly cfc = new ControlFlowContext(); + /** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */ + private readonly handlers: number[] = []; + /** Label(s) pending attachment to the NEXT pushed loop/switch frame. */ + private pendingLabels: string[] = []; + + constructor( + private readonly builder: CfgBuilder, + private readonly harvest: DartHarvester, + ) {} + + /** Named statements of a `block`, ignoring comments. */ + private statementsOf(block: SyntaxNode): SyntaxNode[] { + return block.namedChildren.filter((c) => !isComment(c)); + } + + /** Unwrap a body STMT: a `block` yields its statements; a bare statement is itself. */ + private visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (node.type === 'block') return this.visitSeq(this.statementsOf(node)); + if (isComment(node)) return null; + return this.visitStmt(node); + } + + /** Wire a sequence of statements, coalescing straight-line runs into blocks. */ + visitSeq(stmts: SyntaxNode[]): SeqResult { + let entry: number | undefined; + let dangling: number[] = []; + let openSimple: number | undefined; + + for (const stmt of stmts) { + if (this.isControlFlow(stmt)) { + openSimple = undefined; // close any open straight-line block + const res = this.visitStmt(stmt); + if (res === null) continue; // transparent (empty nested block / label-only) + if (entry === undefined) entry = res.entry; + else this.builder.connect(dangling, res.entry, 'seq'); + dangling = [...res.exits]; + } else { + if (openSimple === undefined) { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + if (entry === undefined) entry = idx; + else this.builder.connect(dangling, idx, 'seq'); + openSimple = idx; + dangling = [idx]; + } else { + this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text, this.harvest.facts(stmt)); + } + } + } + + if (entry === undefined) return null; + return { entry, exits: dangling }; + } + + /** Whether a statement node breaks the current straight-line block. */ + private isControlFlow(stmt: SyntaxNode): boolean { + if (this.isLabelError(stmt)) return true; // a stray label sibling — queue it + if (isThrowStatement(stmt) || isRethrowStatement(stmt)) return true; + return CONTROL_FLOW_TYPES.has(stmt.type); + } + + /** + * A labeled LOOP outside a switch (`outer: for …`) is mis-parsed by + * tree-sitter-dart as a stray `ERROR [identifier :]` SIBLING preceding the + * loop. Recognize that exact shape so the label still resolves a `break outer`. + */ + private isLabelError(stmt: SyntaxNode): boolean { + if (stmt.type !== 'ERROR') return false; + const id = stmt.namedChildren.find((c) => c.type === 'identifier'); + return id !== undefined && stmt.children.some((c) => c.type === ':'); + } + + /** Dispatch one statement to its handler. Non-null except for empty / label-only. */ + visitStmt(stmt: SyntaxNode): SeqResult { + if (this.isLabelError(stmt)) { + const id = stmt.namedChildren.find((c) => c.type === 'identifier'); + if (id?.text) this.pendingLabels = [...this.pendingLabels, id.text]; + return null; // emits no block of its own + } + if (isThrowStatement(stmt)) return this.visitThrow(stmt); + if (isRethrowStatement(stmt)) return this.visitRethrow(stmt); + switch (stmt.type) { + case 'if_statement': + return this.visitIf(stmt); + case 'for_statement': + return this.visitFor(stmt); + case 'while_statement': + return this.visitWhile(stmt); + case 'do_statement': + return this.visitDoWhile(stmt); + case 'switch_statement': + return this.visitSwitch(stmt); + case 'try_statement': + return this.visitTry(stmt); + case 'return_statement': + return this.visitReturn(stmt); + case 'break_statement': + return this.visitBreak(stmt); + case 'continue_statement': + return this.visitContinue(stmt); + case 'assert_statement': + return this.visitAssert(stmt); + case 'block': + return this.visitSeq(this.statementsOf(stmt)); + default: + return this.visitSimple(stmt); + } + } + + private visitSimple(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + return { entry: idx, exits: [idx] }; + } + + /** Take and clear the labels queued by a preceding label sibling. */ + private takeLabels(): string[] { + const labels = this.pendingLabels; + this.pendingLabels = []; + return labels; + } + + // ── jumps (return / throw / rethrow / break / continue / assert) ────────── + + /** `return [expr];` — threads through every active finalizer before EXIT. */ + private visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + wireJumpThroughFinalizers( + this.builder, + idx, + this.cfc.finalizersForReturn(), + this.builder.exitIndex, + 'return', + ); + return { entry: idx, exits: [] }; + } + + /** `throw e;` — routes to the nearest enclosing handler (catch/finally), else EXIT. */ + private visitThrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + /** + * `rethrow;` — re-raises the current exception. Inside a handler it propagates + * past the CURRENT catch to the next-outer handler / EXIT, which is exactly + * what `currentHandler()` resolves to (the in-flight catch is not on the + * handler stack while its own body is walked). Terminates its block. + */ + private visitRethrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + private visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveBreak(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break'); + return { entry: idx, exits: [] }; + } + + private visitContinue(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.jumpLabel(stmt); + const res = this.cfc.resolveContinue(label); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue'); + return { entry: idx, exits: [] }; + } + + /** + * `assert ( cond [, msg] );` — may throw an `AssertionError`. Modeled as a + * straight-line block that ALSO edges to the current handler (the assertion + * may fire), so the error path stays represented while the success path falls + * through. Conservative; deduped by the builder. + */ + private visitAssert(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [idx] }; + } + + /** The `identifier` label of a `break outer;` / `continue outer;`, if any. */ + private jumpLabel(stmt: SyntaxNode): string | undefined { + const id = stmt.namedChildren.find((c) => c.type === 'identifier'); + return id?.text || undefined; + } + + // ── branches (if/else) ───────────────────────────────────────────────────── + + /** + * `if ( COND ) consequence:STMT [ else alternative:STMT ]`. The consequence / + * alternative are a `block` or a bare statement; an `else if` is the nested + * `if_statement` in the `alternative` field. + */ + private visitIf(stmt: SyntaxNode): TraversalResult { + const cond = this.parenCondition(stmt); + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? `if (${cond.text})` : 'if', + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + + const consequence = stmt.childForFieldName('consequence'); + const alternative = stmt.childForFieldName('alternative'); + + const exits: number[] = []; + const thenRes = this.visitBody(consequence); + if (thenRes) { + this.builder.edge(header, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(header); // empty then — true path falls through + } + + if (alternative) { + const elseRes = this.visitBody(alternative); + if (elseRes) { + this.builder.edge(header, elseRes.entry, 'cond-false'); + exits.push(...elseRes.exits); + } else { + exits.push(header); + } + } else { + exits.push(header); // no else — false path falls through to the join + } + + return { entry: header, exits: [...new Set(exits)] }; + } + + /** The condition expression of an `if` — the named child between `(` and `)`. */ + private parenCondition(stmt: SyntaxNode): SyntaxNode | undefined { + let sawOpen = false; + for (let i = 0; i < stmt.childCount; i++) { + const c = stmt.child(i); + if (!c) continue; + if (c.type === '(') { + sawOpen = true; + continue; + } + if (c.type === ')') return undefined; + if (sawOpen && c.isNamed && !isComment(c)) return c; + } + return undefined; + } + + // ── loops ─────────────────────────────────────────────────────────────── + + /** `for ( for_loop_parts ) body:STMT` (C-style and for-in). */ + private visitFor(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const parts = stmt.namedChildren.find((c) => c.type === 'for_loop_parts'); + const header = this.builder.newBlock( + startLineOf(stmt), + parts ? endLineOf(parts) : startLineOf(stmt), + this.forHeaderText(stmt, parts), + 'normal', + this.harvest.forHeadFacts(parts), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, labels); + const body = this.visitBody(stmt.childForFieldName('body')); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-iterates + } + // Structural exit edge — even `for (;;) {}` keeps EXIT reverse-reachable. + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + private forHeaderText(stmt: SyntaxNode, parts: SyntaxNode | undefined): string { + return parts ? `for ${parts.text}` : 'for'; + } + + /** `while condition:parenthesized_expression body:STMT`. */ + private visitWhile(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const cond = stmt.childForFieldName('condition'); + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? `while ${cond.text}` : 'while', + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, labels); + const body = this.visitBody(stmt.childForFieldName('body')); + this.cfc.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-tests + } + // Structural exit edge — even `while (true) {}` keeps EXIT reverse-reachable. + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + /** + * `do body:STMT while condition:… ;` — BOTTOM-TEST: the body runs at least + * once, THEN the condition decides whether to loop back. + */ + private visitDoWhile(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const cond = stmt.childForFieldName('condition'); + const condBlock = this.builder.newBlock( + cond ? startLineOf(cond) : endLineOf(stmt), + cond ? endLineOf(cond) : endLineOf(stmt), + cond ? `while ${cond.text}` : 'while', + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + // `continue` re-tests the condition; `break` leaves the loop. + this.cfc.pushLoop(condBlock, loopExit, labels); + const body = this.visitBody(stmt.childForFieldName('body')); + this.cfc.pop(); + + const backTarget = body ? body.entry : condBlock; + if (body) this.builder.connect(body.exits, condBlock, 'seq'); + this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again + // Structural exit edge — even `do {} while (true)` keeps EXIT reachable. + this.builder.edge(condBlock, loopExit, 'cond-false'); + return { entry: backTarget, exits: [loopExit] }; + } + + // ── switch (no implicit fallthrough; empty-case + continue-label spill) ───── + + /** + * `switch condition:parenthesized_expression body:switch_block`. A non-empty + * case rejoins after the switch (no implicit fallthrough). An EMPTY case (no + * statements) spills into the next case (Dart empty-case fallthrough). An + * explicit `continue LABEL;` jumps to the labeled case. + */ + private visitSwitch(stmt: SyntaxNode): TraversalResult { + const labels = this.takeLabels(); + const value = stmt.childForFieldName('condition'); + const dispatch = this.builder.newBlock( + startLineOf(stmt), + value ? endLineOf(value) : startLineOf(stmt), + value ? `switch ${value.text}` : 'switch', + 'normal', + value ? this.harvest.facts(value) : undefined, + ); + const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + const block = stmt.childForFieldName('body'); + const cases = block + ? block.namedChildren.filter( + (c) => c.type === 'switch_statement_case' || c.type === 'switch_statement_default', + ) + : []; + + this.cfc.pushSwitch(switchExit, labels); + + // Phase 1: build each case body. Register case labels so `continue LABEL` + // can target a labeled case's entry. + const caseBodies: SeqResult[] = []; + const caseLabels: (string | undefined)[] = []; + for (const c of cases) { + caseLabels.push(this.caseLabel(c)); + // A case-pattern test runs conditionally before the body — harvest its + // uses onto the dispatch block (a later case tests only if earlier ones + // didn't match; any def there is a may-def). + for (const pat of this.casePatterns(c)) { + this.builder.attachFacts(dispatch, this.harvest.factsConditional(pat)); + } + caseBodies.push(this.visitSeq(this.caseStatements(c))); + } + + // The entry block of each case (its body, or the NEXT non-empty case's entry + // for an empty fallthrough case, or switchExit when nothing follows). + const entryOf: number[] = new Array(cases.length); + let after = switchExit; + for (let i = cases.length - 1; i >= 0; i--) { + entryOf[i] = caseBodies[i]?.entry ?? after; + after = entryOf[i]; + } + + // Resolve a `continue LABEL` target: the entry of the case carrying LABEL. + const labelTarget = (name: string): number | undefined => { + const idx = caseLabels.findIndex((l) => l === name); + return idx >= 0 ? entryOf[idx] : undefined; + }; + + const hasDefault = cases.some((c) => c.type === 'switch_statement_default'); + + // Dispatch edges: one per case. + for (let i = 0; i < cases.length; i++) { + this.builder.edge(dispatch, entryOf[i], 'switch-case'); + } + if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path + + // Case-body completion: a non-empty case rejoins after the switch UNLESS it + // ends in an explicit `continue LABEL` (handled as the body's own terminator) + // — and an EMPTY case spills into the next case (fallthrough edge). A bare + // `break;` inside a case is a normal break to switchExit (the jump handler). + for (let i = 0; i < cases.length; i++) { + const res = caseBodies[i]; + const contLabel = this.caseContinueLabel(cases[i]); + if (!res) { + // Empty case — spill to the next case (or switchExit). + this.builder.edge(dispatch, i + 1 < cases.length ? entryOf[i + 1] : switchExit, 'fallthrough'); + continue; + } + if (contLabel) { + const tgt = labelTarget(contLabel); + if (tgt !== undefined) this.builder.connect(res.exits, tgt, 'fallthrough'); + else this.builder.connect(res.exits, switchExit, 'seq'); + } else { + this.builder.connect(res.exits, switchExit, 'seq'); + } + } + + this.cfc.pop(); + return { entry: dispatch, exits: [switchExit] }; + } + + /** The `label` name of a `switch_statement_case` (`myLabel: case …`), if any. */ + private caseLabel(c: SyntaxNode): string | undefined { + const label = c.namedChildren.find((ch) => ch.type === 'label'); + const id = label?.namedChildren.find((ch) => ch.type === 'identifier'); + return id?.text || undefined; + } + + /** + * Case-pattern nodes of a `switch_statement_case` whose test evaluates before + * the body — the only DISTINCT pattern node tree-sitter-dart emits is + * `constant_pattern` (`case 1:` / `case 1 || 2:`); a relational guard + * (`case > 5:`) is a bare `relational_operator` + operand, not a wrapper node. + * Harvesting the `constant_pattern` uses onto the dispatch block covers the + * common case; the rest fold into the body walk. + */ + private casePatterns(c: SyntaxNode): SyntaxNode[] { + return c.namedChildren.filter((ch) => ch.type === 'constant_pattern'); + } + + /** Body statements of a switch case/default (skip the case keyword/pattern/label). */ + private caseStatements(c: SyntaxNode): SyntaxNode[] { + const NON_BODY = new Set(['case_builtin', 'label', 'constant_pattern']); + // A `continue LABEL` at a case's tail is its terminator, not a body statement + // whose target falls through — but it IS still a statement for harvesting; we + // drop it from the body here and handle it via `caseContinueLabel`. + return c.namedChildren.filter( + (ch) => !isComment(ch) && !NON_BODY.has(ch.type) && ch.type !== 'continue_statement', + ); + } + + /** A trailing `continue LABEL;` in a case spills to the labeled case. */ + private caseContinueLabel(c: SyntaxNode): string | undefined { + const cont = c.namedChildren.find((ch) => ch.type === 'continue_statement'); + if (!cont) return undefined; + const id = cont.namedChildren.find((ch) => ch.type === 'identifier'); + return id?.text || undefined; + } + + // ── try / on / catch / finally ───────────────────────────────────────────── + + /** + * `try body:block (on TYPE? catch_clause? block)* finally_clause?`. The handler + * groups are bare siblings: an `on type_identifier`, an optional `catch_clause` + * (→ `catch_parameters`), and the handler `block`. The `finally` runs on BOTH + * normal and exception exit — a `return`/`break`/`continue` crossing it threads + * through (`finally-*` completion edges). Mirrors the Java/Kotlin `visitTry`. + */ + private visitTry(stmt: SyntaxNode): SeqResult { + const bodyNode = stmt.childForFieldName('body'); + const finallyClause = stmt.namedChildren.find((c) => c.type === 'finally_clause'); + const finallyBody = finallyClause?.namedChildren.find((c) => c.type === 'block'); + const handlerGroups = this.handlerGroups(stmt); + + // The explicit finally is a finalizer the whole protected region threads through. + const finallyRes = finallyBody ? this.visitSeq(this.statementsOf(finallyBody)) : null; + const finallyFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null; + const finalizerEntry = finallyRes?.entry; + const finalizerExits = finallyRes?.exits ?? null; + + // Build each catch/on handler. + const catchExits: number[] = []; + let firstCatchEntry: number | undefined; + for (const group of handlerGroups) { + const clauseBody = group.block; + if (finalizerEntry !== undefined) this.handlers.push(finalizerEntry); + let res: SeqResult = clauseBody ? this.visitSeq(this.statementsOf(clauseBody)) : null; + if (finalizerEntry !== undefined) this.handlers.pop(); + if (res === null) { + // Empty `on T { }` / `catch (e) {}` still catches — synthesize one block + // so exception flow lands somewhere and post-try code stays reachable. + const anchor = clauseBody ?? group.catchClause ?? stmt; + const idx = this.builder.newBlock(startLineOf(anchor), endLineOf(anchor), ''); + res = { entry: idx, exits: [idx] }; + } + const paramFacts = this.harvest.catchParamFacts(group.catchParameters); + if (paramFacts) { + const anchor = group.catchParameters ?? group.catchClause ?? stmt; + const paramBlock = this.builder.newBlock( + startLineOf(anchor), + startLineOf(anchor), + '', + 'normal', + paramFacts, + ); + this.builder.edge(paramBlock, res.entry, 'seq'); + res = { entry: paramBlock, exits: res.exits }; + } + catchExits.push(...res.exits); + if (firstCatchEntry === undefined) firstCatchEntry = res.entry; + } + + // Handler for the try body: first catch if present, else the finally, else outer. + const tryHandler = firstCatchEntry ?? finalizerEntry ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null; + this.handlers.pop(); + + if (handlerGroups.length > 0 || finalizerEntry !== undefined) { + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, tryHandler, 'throw'); + } + } + + // Pop the finalizer frame and drain its pending completion legs. + if (finallyFrame && finallyRes) { + this.cfc.pop(); + drainFinalizerPending(this.builder, finallyFrame, finallyRes.exits); + } + + const exits: number[] = []; + if (finalizerEntry !== undefined) { + if (bodyRes) this.builder.connect(bodyRes.exits, finalizerEntry, 'seq'); + for (const e of catchExits) this.builder.edge(e, finalizerEntry, 'seq'); + if (finalizerExits) exits.push(...finalizerExits); + // No catch → an exception re-propagates out after the finally runs. + if (handlerGroups.length === 0 && finalizerExits) { + this.builder.connect(finalizerExits, this.currentHandler(), 'throw'); + } + } else { + if (bodyRes) exits.push(...bodyRes.exits); + exits.push(...catchExits); + } + + const entry = bodyRes?.entry ?? finalizerEntry ?? firstCatchEntry; + if (entry === undefined) return null; + return { entry, exits: [...new Set(exits)] }; + } + + /** + * The handler groups of a `try_statement`: each is an `on TYPE` and/or + * `catch (params)` plus the handler `block` that follows it. The `on`/`type`/ + * `catch_clause`/`block` are bare children in source order, so a group is a + * `catch_clause` (or an `on` keyword + `type_identifier`) followed by a `block`. + */ + private handlerGroups( + stmt: SyntaxNode, + ): Array<{ catchClause?: SyntaxNode; catchParameters?: SyntaxNode; block?: SyntaxNode }> { + const groups: Array<{ + catchClause?: SyntaxNode; + catchParameters?: SyntaxNode; + block?: SyntaxNode; + }> = []; + const bodyNode = stmt.childForFieldName('body'); + let pendingCatch: SyntaxNode | undefined; + let sawOn = false; + for (let i = 0; i < stmt.childCount; i++) { + const c = stmt.child(i); + if (!c) continue; + if (c.type === 'finally_clause') break; // finally is separate + if (c.type === 'on') { + sawOn = true; + continue; + } + if (c.type === 'catch_clause') { + pendingCatch = c; + continue; + } + if (c.type === 'block' && c !== bodyNode) { + const catchParameters = pendingCatch?.namedChildren.find( + (ch) => ch.type === 'catch_parameters', + ); + groups.push({ catchClause: pendingCatch, catchParameters, block: c }); + pendingCatch = undefined; + sawOn = false; + } + } + // A trailing `on T catch (e)` with no `{}` body is malformed; ignore here. + void sawOn; + return groups; + } + + /** Nearest enclosing exception handler, or the function EXIT. */ + private currentHandler(): number { + return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex; + } +} + +/** The signature node that precedes a `function_body` (params live there). */ +function signatureFor(fnNode: SyntaxNode): SyntaxNode | undefined { + if (fnNode.type !== 'function_body') return undefined; + const prev = fnNode.previousSibling; + if (!prev) return undefined; + if (DART_SIGNATURE_TYPES.has(prev.type)) return prev; + // A `method_signature` wraps a `function_signature` / getter / setter — the + // params live on the wrapped signature for a plain method but the wrapper still + // carries them transitively; return the wrapper (paramList searches its named + // children, which include the `formal_parameter_list`). + return undefined; +} + +/** The body STMT of a `function_body` / `function_expression`: a `block` or an arrow expr. */ +function bodyAndArrow(fnNode: SyntaxNode): { block?: SyntaxNode; arrowExpr?: SyntaxNode } { + let container: SyntaxNode | undefined = fnNode; + if (fnNode.type === 'function_expression') { + container = fnNode.childForFieldName('body') ?? undefined; // function_expression_body + } + if (!container) return {}; + const block = container.namedChildren.find((c) => c.type === 'block'); + if (block) return { block }; + // Arrow body `=> expr`: the expression is the first non-comment named child. + const arrowExpr = container.namedChildren.find((c) => !COMMENT_TYPES.has(c.type)); + return { arrowExpr }; +} + +/** Build the CFG for one Dart function node, or `undefined` if not modelable. */ +function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { + try { + if (!isFunction(fnNode)) return undefined; + const signature = signatureFor(fnNode); + // Anchor the span/column to the signature when present (so same-line + // functions `void a(){} void b(){}` get distinct start columns from the + // signature, and the span covers the declaration head), else the body node. + const anchor = signature ?? fnNode; + const startLine = startLineOf(anchor); + const endLine = endLineOf(fnNode); + const startColumn = anchor.startPosition.column; + + const { block, arrowExpr } = bodyAndArrow(fnNode); + + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new DartHarvester(fnNode, signature); + + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + + // Arrow body (`=> expr`): one block whose value is returned. + if (!block && arrowExpr) { + const blk = builder.newBlock( + startLineOf(arrowExpr), + endLineOf(arrowExpr), + arrowExpr.text, + 'normal', + harvest.facts(arrowExpr), + ); + builder.edge(builder.entryIndex, blk, 'seq'); + builder.edge(blk, builder.exitIndex, 'return'); + return builder.finish(harvest.bindingTable()); + } + + const walk = new DartCfgWalk(builder, harvest); + const res = block + ? walk.visitSeq(block.namedChildren.filter((c) => !isComment(c))) + : null; + + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(harvest.bindingTable()); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + return builder.finish(harvest.bindingTable()); + } catch (err) { + // Never throw out of buildFunctionCfg — a malformed AST shape must skip only + // this one function's CFG, never drop the whole file's language group (R4). + // eslint-disable-next-line no-console + console.warn(`[cfg] Dart buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`); + return undefined; + } +} + +/** + * Whether a node is a Dart function this visitor builds a CFG for: a + * `function_body` whose previous sibling is a signature (top-level fn / method / + * getter / setter / constructor), or a `function_expression` (a closure). + */ +function isFunction(node: SyntaxNode): boolean { + if (node.type === 'function_expression') return true; + if (node.type !== 'function_body') return false; + const prev = node.previousSibling; + return prev !== null && DART_SIGNATURE_TYPES.has(prev.type); +} + +/** The Dart CFG visitor. */ +export function createDartCfgVisitor(): CfgVisitor { + return { buildFunctionCfg, isFunction }; +} + +export { DART_SIGNATURE_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/dart.ts b/gitnexus/src/core/ingestion/languages/dart.ts index e79ad4708..b2a71ec81 100644 --- a/gitnexus/src/core/ingestion/languages/dart.ts +++ b/gitnexus/src/core/ingestion/languages/dart.ts @@ -22,6 +22,7 @@ import { dartExportChecker } from '../export-detection.js'; import { createImportResolver } from '../import-resolvers/resolver-factory.js'; import { dartImportConfig } from '../import-resolvers/configs/dart.js'; import { DART_QUERIES } from '../tree-sitter-queries.js'; +import { createDartCfgVisitor } from '../cfg/visitors/dart.js'; import { createFieldExtractor } from '../field-extractors/generic.js'; import { dartConfig as dartFieldConfig } from '../field-extractors/configs/dart.js'; import { createMethodExtractor } from '../method-extractors/generic.js'; @@ -131,6 +132,7 @@ export const dartProvider = defineLanguage({ // emit-side `ScopeResolver` lives in `dart/scope-resolver.ts`; the same // function references flow through both interfaces. emitScopeCaptures: emitDartScopeCaptures, + cfgVisitor: createDartCfgVisitor(), interpretImport: interpretDartImport, interpretTypeBinding: interpretDartTypeBinding, bindingScopeFor: dartBindingScopeFor, diff --git a/gitnexus/test/integration/cfg/fixtures/dart-hazards.dart b/gitnexus/test/integration/cfg/fixtures/dart-hazards.dart new file mode 100644 index 000000000..9ef3d4790 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/dart-hazards.dart @@ -0,0 +1,134 @@ +// Dart CFG hazard fixture (#2195) — one of every control-flow shape the Dart +// CfgVisitor models, so the worker-roundtrip / integration passes exercise the +// real (vendored) grammar end-to-end. Mirrors the sibling fixtures +// (kotlin-hazards, swift-hazards, java-hazards). Dart splits a function into a +// SIGNATURE + a sibling `function_body`, and `if`/`switch`-expression/`?:`/`??` +// are expressions — this fixture stresses statement-position constructs plus a +// few value-position ones (which stay inline as harvest may-defs). + +// if / else-if / else + a value-position `?:` (stays inline) + arrow body. +int branching(int x) { + if (x > 0) { + positive(); + } else if (x < 0) { + negative(); + } else { + zero(); + } + final sign = x >= 0 ? 1 : -1; // value-position conditional — not a branch + return sign; +} + +// switch statement: empty-case fallthrough, an explicit `continue LABEL`, a +// default, and a no-implicit-fallthrough non-empty case. +void dispatch(int x) { + switch (x) { + case 1: + case 2: + small(); + break; + case 3: + medium(); + continue big; + big: + case 4: + large(); + break; + default: + other(); + } + after(); +} + +// switch EXPRESSION used as a value — stays inline (no branch edges). +int classify(int x) { + return switch (x) { + 1 => 10, + 2 => 20, + _ => 0, + }; +} + +// for-in (binds the loop var) + c-style for + while + do-while (bottom-test). +void loops(List items) { + for (var item in items) { + consume(item); + } + for (var i = 0; i < 10; i++) { + index(i); + } + while (running()) { + tick(); + } + do { + retry(); + } while (shouldRetry()); +} + +// while (true) {} — keeps EXIT reverse-reachable (structural escape edge). +void spin(bool stop) { + while (true) { + if (stop) { + break; + } + work(); + } + done(); +} + +// try / on / catch / finally — exception flow + finally completion edges. +int guarded() { + try { + return risky(); + } on FormatException catch (e, st) { + handle(e, st); + } catch (err) { + fallback(err); + } finally { + cleanup(); + } + return 0; +} + +// rethrow — re-raises past the current handler. +void propagate() { + try { + risky(); + } catch (e) { + log(e); + rethrow; + } +} + +// labeled break/continue across nested loops. +void labeled(List xs, List ys) { + outer: + for (var i in xs) { + for (var j in ys) { + if (match(i, j)) { + break outer; + } + continue outer; + } + } + done(); +} + +// harvest: var / final / typed locals, compound assign, member write (not a +// scalar def), null-coalescing (`??`) as a may-def, and a closure (own CFG). +int harvest(List xs, int? maybe) { + var total = 0; + final base = compute(); + int n = base ?? 1; + total += base; + xs.forEach((e) { + total += e; + }); + return n; +} + +// assert may throw — modeled as a straight-line block with a handler edge. +void checked(bool ok) { + assert(ok, 'must be ok'); + proceed(); +} diff --git a/gitnexus/test/unit/cfg/dart-visitor.test.ts b/gitnexus/test/unit/cfg/dart-visitor.test.ts new file mode 100644 index 000000000..4ca347f4f --- /dev/null +++ b/gitnexus/test/unit/cfg/dart-visitor.test.ts @@ -0,0 +1,424 @@ +import { describe, it, expect } from 'vitest'; +import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; +import { createDartCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/dart.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; + +// The Dart CfgVisitor, one hazard per test (real-parser regression, NOT +// snapshot-pinning). Dart's grammar is VENDORED (not an npm package): the grammar +// loads from vendor/ via `requireVendoredGrammar('tree-sitter-dart')`, exactly +// like the Kotlin/Swift tests load their vendored grammars. Dart also splits a +// function into a SIGNATURE + a SIBLING `function_body`, so `isFunction` selects +// the body node — the harness collects those transparently. Each fixture's +// distinctive statement text (step(), done(), handle(e), …) lets us locate the +// block for a region by text and assert the control-flow topology around it. + +const dartGrammar = requireVendoredGrammar('tree-sitter-dart') as Parameters< + typeof makeCfgHarness +>[0]; + +const dart: CfgHarness = makeCfgHarness(dartGrammar, createDartCfgVisitor(), 'fixture.dart'); + +const block = (cfg: FunctionCfg, substr: string): number => { + const b = cfg.blocks.find((bl) => bl.text.includes(substr)); + if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`); + return b.index; +}; + +const edgeKinds = (cfg: FunctionCfg): Set => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +const definesBinding = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx))); +const usesBinding = (cfg: FunctionCfg, idx: number): boolean => + cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx))); + +describe('Dart CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT (seq)', () => { + const cfg = dart.cfgOf(`void f() { a(); b(); c(); }`); + expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: body, kind: 'seq' }); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty body: ENTRY → EXIT', () => { + const cfg = dart.cfgOf(`void f() {}`); + expect(cfg.blocks).toHaveLength(2); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('arrow-body function: ENTRY → block → EXIT (return)', () => { + const cfg = dart.cfgOf(`int f(int x) => x + 1;`); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + expect(edgeKinds(cfg).has('return')).toBe(true); + // The parameter is bound and used. + expect(definesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + expect(usesBinding(cfg, bindingIdx(cfg, 'x'))).toBe(true); + }); + + it('an unmodeled shape (abstract method, no body) builds no CFG and never throws', () => { + // An abstract method declaration has a `function_signature`/`method_signature` + // but NO sibling `function_body` — `isFunction` rejects it; a real function + // still builds. buildFunctionCfg must never throw. + const root = dart.parse(`abstract class P { void f(); }`); + const fns = dart.collectFunctions(root); + for (const fn of fns) { + expect(() => createDartCfgVisitor().buildFunctionCfg(fn, 'p.dart')).not.toThrow(); + } + const cfg = dart.cfgOf(`void g() { x(); }`); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + 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); + expect(definesBinding(cfg, bindingIdx(cfg, 'a'))).toBe(true); + }); + + it('a getter with an arrow body builds a CFG', () => { + const cfgs = dart.cfgsOf(`class C { int get v => 3; }`); + expect(cfgs.length).toBeGreaterThanOrEqual(1); + const getter = cfgs[0]; + expect(reaches(getter, getter.entryIndex, getter.exitIndex)).toBe(true); + expect(edgeKinds(getter).has('return')).toBe(true); + }); +}); + +describe('Dart CfgVisitor — branching (if/else)', () => { + it('if/else: cond-true to then, cond-false to else, both reach the join', () => { + const cfg = dart.cfgOf(`void f(int x) { if (x > 0) { a(); } else { b(); } c(); }`); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const join = block(cfg, 'c();'); + expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true); + }); + + it('else-if chain branches each condition', () => { + const cfg = dart.cfgOf( + `void f(int x) { if (x == 1) { a(); } else if (x == 2) { b(); } else { c(); } d(); }`, + ); + const join = block(cfg, 'd();'); + expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'c();'), join)).toBe(true); + }); + + it('if without braces (bare body statement) still branches', () => { + const cfg = dart.cfgOf(`void f(int x) { if (x > 0) a(); else b(); c(); }`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'c();'))).toBe(true); + }); +}); + +describe('Dart CfgVisitor — loops', () => { + it('for-in: header + body + loop-back + exit; binds the loop var', () => { + const cfg = dart.cfgOf(`void f(List xs) { for (var e in xs) { step(e); } done(); }`); + const header = block(cfg, 'for (var e in xs)'); + const body = block(cfg, 'step(e)'); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + expect(definesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true); + }); + + it('for-in over an existing variable `(e in xs)` still defines e each iteration', () => { + const cfg = dart.cfgOf(`void f(List xs) { var e; for (e in xs) { use(e); } }`); + expect(definesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true); + expect(usesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true); + }); + + it('c-style for: header tests, body loops back', () => { + const cfg = dart.cfgOf(`void f() { for (var i = 0; i < 10; i++) { step(i); } done(); }`); + const header = block(cfg, 'for (var i = 0'); + const body = block(cfg, 'step(i)'); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + expect(definesBinding(cfg, bindingIdx(cfg, 'i'))).toBe(true); + }); + + it('while: header tests first, body loops back', () => { + const cfg = dart.cfgOf(`void f() { while (cond()) { step(); } done(); }`); + const header = block(cfg, 'while (cond())'); + const body = block(cfg, 'step();'); + expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' }); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true); + }); + + it('do-while runs the body BEFORE testing, then loops back from the bottom', () => { + const cfg = dart.cfgOf(`void f() { do { step(); } while (cond()); done(); }`); + const body = block(cfg, 'step();'); + const cond = block(cfg, 'while (cond())'); + expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first + expect(reaches(cfg, body, cond)).toBe(true); // condition tests at the bottom + expect(cfg.edges).toContainEqual({ from: cond, to: body, kind: 'loop-back' }); + expect(reaches(cfg, cond, block(cfg, 'done();'))).toBe(true); + }); + + it('while (true) {} keeps EXIT reverse-reachable (structural exit-escape edge)', () => { + const cfg = dart.cfgOf(`void f() { while (true) { work(); } }`); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('for (;;) {} keeps EXIT reverse-reachable', () => { + const cfg = dart.cfgOf(`void f() { for (;;) { work(); } }`); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); +}); + +describe('Dart CfgVisitor — switch', () => { + it('non-empty cases do NOT implicitly fall through; each rejoins after', () => { + const cfg = dart.cfgOf(`void f(int x) { + switch (x) { + case 1: one(); break; + case 2: two(); break; + default: other(); + } + after(); + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + // case 1 body does NOT flow into case 2 body (no implicit fallthrough). + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false); + // every arm reaches the post-switch continuation. + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'after();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'two();'), block(cfg, 'after();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'other();'), block(cfg, 'after();'))).toBe(true); + }); + + it('an EMPTY case falls through to the next case (Dart empty-case fallthrough)', () => { + const cfg = dart.cfgOf(`void f(int x) { + switch (x) { + case 1: + case 2: + a(); + break; + default: + d(); + } + after(); + }`); + expect(edgeKinds(cfg).has('fallthrough')).toBe(true); + // The empty `case 1:` dispatch reaches the shared `a()` body via fallthrough. + expect(reachable(cfg, block(cfg, 'a();'))).toBe(true); + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'after();'))).toBe(true); + }); + + it('explicit `continue LABEL;` jumps to the labeled case', () => { + const cfg = dart.cfgOf(`void f(int x) { + switch (x) { + case 1: a(); continue done; + case 2: b(); break; + done: + case 3: c(); break; + default: d(); + } + after(); + }`); + expect(edgeKinds(cfg).has('fallthrough')).toBe(true); + // case 1's `continue done` reaches the labeled case 3 body c(). + expect(reaches(cfg, block(cfg, 'a();'), block(cfg, 'c();'))).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('a switch with NO default lets the no-match path fall to the join', () => { + const cfg = dart.cfgOf(`void f(int x) { switch (x) { case 1: a(); break; } after(); }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('a switch EXPRESSION used as a value stays inline (no branch edges)', () => { + const cfg = dart.cfgOf(`void f(int x) { + var y = switch (x) { 1 => one(), 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); + }); +}); + +describe('Dart CfgVisitor — try/on/catch/finally', () => { + it('try/on/catch/finally: a throw edge runs to the handler; finally completion edges', () => { + const cfg = dart.cfgOf(`void f() { + try { risky(); } + on Exception catch (e, st) { handle(e, st); } + finally { cleanup(); } + after(); + }`); + const kinds = edgeKinds(cfg); + expect(kinds.has('throw')).toBe(true); + const handler = block(cfg, 'handle(e, st)'); + expect(reaches(cfg, block(cfg, 'risky();'), handler)).toBe(true); + // the finally runs on both normal and exception exit. + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true); + expect(reaches(cfg, handler, fin)).toBe(true); + // after() is still reachable (finally completion rejoins). + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + // the `on … catch (e, st)` binds both error names. + expect(definesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true); + expect(definesBinding(cfg, bindingIdx(cfg, 'st'))).toBe(true); + }); + + it('try with finally only (no catch) still threads cleanup on both paths', () => { + const cfg = dart.cfgOf(`void f() { try { risky(); } finally { cleanup(); } after(); }`); + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true); + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('a return inside a try threads through the finally (finally-return completion)', () => { + const cfg = dart.cfgOf(`int f() { + try { return compute(); } finally { cleanup(); } + }`); + const kinds = edgeKinds(cfg); + expect(kinds.has('return')).toBe(true); + expect(kinds.has('finally-return')).toBe(true); + const fin = block(cfg, 'cleanup();'); + expect(reaches(cfg, fin, cfg.exitIndex)).toBe(true); + }); + + it('rethrow re-routes to the outer handler / EXIT and ends its block', () => { + const cfg = dart.cfgOf(`void f() { try { risky(); } catch (e) { rethrow; } }`); + const re = block(cfg, 'rethrow;'); + expect(edgeKinds(cfg).has('throw')).toBe(true); + // rethrow with no outer handler propagates to EXIT. + expect(cfg.edges).toContainEqual({ from: re, to: cfg.exitIndex, kind: 'throw' }); + expect(reaches(cfg, re, cfg.exitIndex)).toBe(true); + expect(definesBinding(cfg, bindingIdx(cfg, 'e'))).toBe(true); + }); + + it('a throw with NO enclosing try routes to EXIT and ends its block', () => { + const cfg = dart.cfgOf(`void f(bool x) { if (x) throw StateError('x'); done(); }`); + const thr = block(cfg, "throw StateError('x')"); + expect(cfg.edges).toContainEqual({ from: thr, to: cfg.exitIndex, kind: 'throw' }); + // throw terminates its block — control does not fall into done() from it. + expect(reaches(cfg, thr, block(cfg, 'done();'))).toBe(false); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); // via the if false branch + }); +}); + +describe('Dart CfgVisitor — labeled break/continue', () => { + it('labeled `break outer` escapes BOTH loops and reaches done()', () => { + const cfg = dart.cfgOf(`void f(List xs, List ys) { + outer: for (var i in xs) { + for (var j in ys) { break outer; } + } + done(); + }`); + const brk = block(cfg, 'break outer;'); + expect(edgeKinds(cfg).has('break')).toBe(true); + expect(reaches(cfg, brk, block(cfg, 'done();'))).toBe(true); + }); + + it('labeled `continue outer` targets the outer loop header', () => { + const cfg = dart.cfgOf(`void f(List xs, List ys) { + outer: for (var i in xs) { + for (var j in ys) { continue outer; } + } + done(); + }`); + expect(edgeKinds(cfg).has('continue')).toBe(true); + const outer = block(cfg, 'for (var i in xs)'); + const cont = block(cfg, 'continue outer;'); + expect(reaches(cfg, cont, outer)).toBe(true); + }); +}); + +describe('Dart CfgVisitor — closures (own CFG)', () => { + it('a closure is collected as its own CFG; both keep EXIT reachable', () => { + const cfgs = dart.cfgsOf(`void f(List xs) { + xs.forEach((e) { + use(e); + }); + }`); + // f and the closure are both CFG-bearing. + expect(cfgs.length).toBeGreaterThanOrEqual(2); + for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + // The closure's CFG is the one that binds its own param `e` (the enclosing + // f's block text also contains `use(e);`, so match on the binding instead). + const closure = cfgs.find((c) => (c.bindings ?? []).some((b) => b.name === 'e')); + expect(closure).toBeDefined(); + expect(definesBinding(closure!, bindingIdx(closure!, 'e'))).toBe(true); + }); +}); + +describe('Dart CfgVisitor — def/use harvest', () => { + it('var x = compute(); use(x) produces a def of x and a use in the consumer', () => { + const cfg = dart.cfgOf(`void f() { var x = compute(); use(x); }`); + const x = bindingIdx(cfg, 'x'); + expect(definesBinding(cfg, x)).toBe(true); + expect(usesBinding(cfg, x)).toBe(true); + }); + + it('final and typed local declarations both define', () => { + const cfg = dart.cfgOf(`void f() { final b = compute(); int c = 3; use(b); use(c); }`); + for (const name of ['b', 'c']) { + const idx = bindingIdx(cfg, name); + expect(definesBinding(cfg, idx)).toBe(true); + expect(usesBinding(cfg, idx)).toBe(true); + } + }); + + it('compound assign `x += step()` defines AND uses x', () => { + const cfg = dart.cfgOf(`void f() { var x = 0; x += step(); }`); + const x = bindingIdx(cfg, 'x'); + expect(definesBinding(cfg, x)).toBe(true); + expect(usesBinding(cfg, x)).toBe(true); + }); + + it('a member write `obj.x = 1` does NOT define a scalar `x`; the root is a use', () => { + const cfg = dart.cfgOf(`void f(C obj) { obj.x = compute(); }`); + // `obj` is used (it is the assignment target's root); no scalar `x` binding. + expect(usesBinding(cfg, bindingIdx(cfg, 'obj'))).toBe(true); + }); +}); + +describe('Dart CfgVisitor — functionStartColumn', () => { + it('two same-line functions get distinct functionStartColumn', () => { + const cfgs = dart.cfgsOf(`void a() { x(); } void b() { y(); }`); + expect(cfgs).toHaveLength(2); + expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine); // same line + expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn); // distinct column + }); +});