mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
b302c63544
commit
2071cadf1e
5 changed files with 2044 additions and 0 deletions
557
gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts
Normal file
557
gitnexus/src/core/ingestion/cfg/visitors/dart-harvest.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
927
gitnexus/src/core/ingestion/cfg/visitors/dart.ts
Normal file
927
gitnexus/src/core/ingestion/cfg/visitors/dart.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
134
gitnexus/test/integration/cfg/fixtures/dart-hazards.dart
Normal file
134
gitnexus/test/integration/cfg/fixtures/dart-hazards.dart
Normal 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();
|
||||
}
|
||||
424
gitnexus/test/unit/cfg/dart-visitor.test.ts
Normal file
424
gitnexus/test/unit/cfg/dart-visitor.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue