feat(cfg): Dart CFG visitor + def/use harvest (#2195 U14)

Add createDartCfgVisitor + dart-harvest (vendored tree-sitter-dart):
if/else, C-for/for-in/while/do-while, switch (empty-case fallthrough +
explicit continue-label) + switch_expression, try/on/catch/finally +
rethrow + assert (throw edges), return/break/continue/throw, labeled
loops, arrow bodies, closures. Dart splits a function into sibling
signature + function_body nodes, so the body (or function_expression) is
the CFG-bearing node. Wire into dartProvider.

Every literal validated against the vendored grammar via the probe (only
constant_pattern exists; removed speculative relational/logical pattern
names). while (true) keeps EXIT reverse-reachable (production CDG probe:
3 edges). 34 real-parser tests; comprehensive sweep green (605). Gaps:
labeled-loop grammar quirk (read via ERROR sibling), async straight-line,
value-position if/switch inline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 14:09:21 +00:00
parent b302c63544
commit 2071cadf1e
5 changed files with 2044 additions and 0 deletions

View file

@ -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<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
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<string, number>();
private readonly synthetic = new Map<string, number>();
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;
}
}

View file

@ -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_<node_type>` 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<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { DART_SIGNATURE_TYPES };

View file

@ -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,

View file

@ -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();
}

View file

@ -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<string> => new Set(cfg.edges.map((e) => e.kind));
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
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
});
});