feat(cfg): Go CFG visitor + def/use harvest (#2195 U5)

Add createGoCfgVisitor + go-harvest, the highest-divergence target:
for_statement (all four shapes -- for_clause C-style, while-style,
range_clause, bare for{}), expression/type switch (no implicit
fallthrough) + explicit fallthrough_statement, select_statement, defer
(LIFO finalizer legs at function exit), go (call is straight-line; the
closure body is its own CFG via isFunction), labeled break/continue/goto,
multiple-return assigns (a, b := f() defines each LHS). Wire into
goProvider.

CRITICAL (review A2): every non-terminating shape -- for{}, for cond{},
select{} with no default -- emits a structural exit-escape edge so EXIT
stays reverse-reachable and the production CDG is not silently skipped.
Verified: for{} -> CDG=3, select{} -> CDG=1, for-range -> CDG=2, all
exitReachable=true.

Every literal validated against tree-sitter-go via the probe. 32
real-parser regression tests; grammar-literal gate green; no regression
(186 across all 5 visitors + gate, full cfg unit 331, tsc clean).
Documented gaps: panic/recover unwind, goroutine happens-before.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 11:33:58 +00:00
parent 6dbe7373ff
commit fff2c1489a
5 changed files with 2097 additions and 0 deletions

View file

@ -0,0 +1,588 @@
/**
* Go def/use harvester (#2195 U5, plan KTD2) — the Go analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the closely-related
* {@link import('./java-harvest.js').JavaHarvester} / {@link
* import('./csharp-harvest.js').CsharpHarvester}.
*
* Runs in the parse worker next to the Go CFG visitor, extracting per-statement
* variable definition/use facts that ride the side channel for the reaching-defs
* / CDG solvers. Output is the per-function binding table ({@link BindingEntry}[])
* plus {@link StatementFacts} the visitor attaches to blocks as it walks.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-go via the introspection probe before use (mandatory pre-step,
* KTD5). Go shapes pre-empted (verified by a real parse):
* - declarations: `short_var_declaration` (`a, b := f()`, fields `left`/`right`
* of `expression_list`s) and `var_declaration` → `var_spec` (fields `name`*,
* `type`?, `value`?) [block form wraps specs in a `var_spec_list`].
* - assignments: `assignment_statement` (fields `left`/`operator`/`right`, all
* `expression_list`s; covers `=`, `+=`, multi-assign `a, b = b, a`), and
* `inc_statement` / `dec_statement` (`x++` / `x--`).
* - loop binders: `range_clause` (`for k, v := range xs`, fields `left`/`right`;
* the `=` reassign form and the bare `for range xs` form both parse here).
* - `selector_expression` (`a.b`, fields `operand`/`field`), `index_expression`
* (`m[k]`, fields `operand`/`index`), `parenthesized_expression`,
* `binary_expression` (fields `left`/`operator`/`right`), `unary_expression`
* (fields `operator`/`operand`; `*p` deref + `<-ch` receive).
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / Java / C#
* harvesters): the CFG walk is NOT source-order (`visitFor` builds the init block
* after the body, the `for`-clause condition before the update), so resolving
* names against a scope stack populated *during* the walk would mis-resolve.
* Phase 1 pre-scans the whole function subtree once into a completed lexical
* scope tree; phase 2 resolves defs/uses against that finished tree from any
* walk order.
*
* v1 def-semantics scope:
* - `short_var_declaration` `:=` — every identifier in the `left`
* `expression_list` is a def (`a, b := f()` defines BOTH `a` and `b`).
* - `var_declaration` → `var_spec` — an INITIALIZED spec (`var x = 1`,
* `var x int = 1`) defines each `name`; a bare `var x int` writes nothing at
* runtime (not a def, the TS bare-`var` rule).
* - `assignment_statement` (plain `=` + compound `+=` …) — each identifier in
* the `left` list is a def; a compound op also USES the lvalue.
* - `inc_statement` / `dec_statement` (`x++` / `x--`) — def AND use the lvalue.
* - parameters (`parameter_declaration` `name`, incl. variadic), the method
* receiver (`method_declaration` `receiver`), and the `range` loop variables
* (`range_clause` `left`).
* EXCLUDED, deliberately (TypeScript-CFA precedent): selector / index / pointer
* writes (`obj.f = …`, `m[k] = …`, `*p = …`) are NOT scalar defs — their root
* identifiers are uses only. Nested-function (`func_literal`) bodies are opaque in
* BOTH directions (writes to and reads of captured outer variables are invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` — is a may-def (gen without kill), so the not-taken
* path's prior def is not falsely killed. (Go has no ternary or `??`; assignment
* is a statement, not an expression, so in-expression assignment defs do not
* occur — `&&`/`||` short-circuit is the only conditional-def shape, and it can
* only surface a may-def via a nested closure, which is opaque anyway. The
* machinery is kept for switch/select case-test parity.)
*
* Identifiers with no in-function declaration (package-level vars, imported
* names, functions) 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([
'func_literal',
'function_declaration',
'method_declaration',
]);
/**
* Nodes that open a lexical scope for block-local declarations. A `block` is one
* scope; the loop / branch constructs open a scope for their init / loop var; a
* switch/select case scopes its case-local declarations.
*/
const SCOPE_TYPES = new Set([
'block',
'for_statement',
'if_statement',
'expression_switch_statement',
'type_switch_statement',
'select_statement',
'expression_case',
'type_case',
'default_case',
'communication_case',
]);
interface Scope {
readonly parent: Scope | null;
/** name → binding index */
readonly table: Map<string, number>;
}
export class GoHarvester {
private readonly bindings: BindingEntry[] = [];
private readonly scopeByNode = new Map<number, Scope>();
private readonly root: Scope = { parent: null, table: new Map() };
private readonly synthetic = new Map<string, number>();
private readonly fnId: number;
/** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */
private readonly nearestScopeCache = new Map<number, Scope>();
/** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */
private conditionalDepth = 0;
constructor(private readonly fnNode: SyntaxNode) {
this.fnId = fnNode.id;
this.scopeByNode.set(fnNode.id, this.root);
this.declareReceiver(fnNode);
this.declareParams(fnNode);
const body = this.bodyOf(fnNode);
if (body && body.type === 'block') this.prescan(body, this.openScope(body));
}
/** The completed binding table — pass to `CfgBuilder.finish`. */
table(): readonly BindingEntry[] {
return this.bindings;
}
/** The function/method/literal body node (always a `block` in Go). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
return fnNode.childForFieldName('body') ?? undefined;
}
// ── phase 1: declaration pre-scan ────────────────────────────────────────
private openScope(node: SyntaxNode): Scope {
const existing = this.scopeByNode.get(node.id);
if (existing) return existing;
const scope: Scope = { parent: this.nearestScopeOf(node), table: new Map() };
this.scopeByNode.set(node.id, scope);
return scope;
}
private nearestScopeOf(node: SyntaxNode): Scope {
for (let p = node.parent; p; p = p.parent) {
const s = this.scopeByNode.get(p.id);
if (s) return s;
if (p.id === this.fnId) break;
}
return this.root;
}
private declare(nameNode: SyntaxNode, kind: BindingEntry['kind'], scope: Scope): void {
const name = nameNode.text;
if (!name || name === '_' || scope.table.has(name)) return; // `_` is the blank identifier
scope.table.set(name, this.bindings.length);
this.bindings.push({
name,
declLine: nameNode.startPosition.row + 1,
declColumn: nameNode.startPosition.column,
kind,
});
}
/** Method receiver: `func (r *T) M()` — `r` binds at function scope. */
private declareReceiver(fnNode: SyntaxNode): void {
const recv = fnNode.childForFieldName('receiver');
if (!recv) return;
for (let i = 0; i < recv.namedChildCount; i++) {
const p = recv.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = p.childForFieldName('name');
if (name) this.declare(name, 'param', this.root);
}
}
private declareParams(fnNode: SyntaxNode): void {
const params = fnNode.childForFieldName('parameters');
if (!params) return;
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration' && p?.type !== 'variadic_parameter_declaration') {
continue;
}
// A single `parameter_declaration` can name several params: `a, b int`.
for (let j = 0; j < p.namedChildCount; j++) {
const c = p.namedChild(j);
if (c?.type === 'identifier') this.declare(c, 'param', this.root);
}
}
}
private prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function literal is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'short_var_declaration': {
// `a, b := …` — every identifier on the left is a fresh binding.
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
break;
}
case 'var_declaration':
this.declareVarDeclaration(node, childScope);
break;
case 'range_clause': {
// `for k, v := range xs` — the `:=` form binds the loop vars; the `=`
// reassign form references existing names (not declared here).
if (this.rangeIsShort(node)) {
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
}
break;
}
case 'type_switch_statement': {
// `switch t := i.(type)` — `t` binds once for the whole switch (the
// per-case narrowed `t` shares the name).
const alias = node.childForFieldName('alias');
if (alias) this.declareIdentifiers(alias, childScope);
break;
}
case 'receive_statement': {
// `case v := <-ch` inside a select — the `:=` form binds `v`.
if (this.receiveIsShort(node)) {
const left = node.childForFieldName('left');
if (left) this.declareIdentifiers(left, childScope);
}
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/** Declare every identifier child of an `expression_list` (LHS of `:=`). */
private declareIdentifiers(list: SyntaxNode, scope: Scope): void {
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'var', scope);
}
}
/** Declare names of an INITIALIZED `var_spec` (a bare `var x int` writes nothing). */
private declareVarDeclaration(declNode: SyntaxNode, scope: Scope): void {
const specs = this.varSpecs(declNode);
for (const spec of specs) {
const hasValue = spec.childForFieldName('value') !== null;
if (!hasValue) continue; // bare `var x int` — not a runtime write
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') this.declare(c, 'var', scope);
}
}
}
/** The `var_spec` nodes of a `var_declaration` (single or `var ( … )` block). */
private varSpecs(declNode: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (let i = 0; i < declNode.namedChildCount; i++) {
const c = declNode.namedChild(i);
if (!c) continue;
if (c.type === 'var_spec') out.push(c);
else if (c.type === 'var_spec_list') {
for (let j = 0; j < c.namedChildCount; j++) {
const s = c.namedChild(j);
if (s?.type === 'var_spec') out.push(s);
}
}
}
return out;
}
/** A `range_clause` is the `:=` short form iff it has no `=` operator token. */
private rangeIsShort(node: SyntaxNode): boolean {
return this.hasAnonChild(node, ':=');
}
/** A `receive_statement` (`case v := <-ch`) is the `:=` short form. */
private receiveIsShort(node: SyntaxNode): boolean {
return this.hasAnonChild(node, ':=');
}
private hasAnonChild(node: SyntaxNode, text: string): boolean {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && c.text === text) return true;
}
return false;
}
// ── 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 … range right` head: the `:=` loop vars are defs (the `=`
* reassign form's vars are also written), and `right` is used.
*/
rangeHeadFacts(rangeClause: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(rangeClause.startPosition.row + 1);
const left = rangeClause.childForFieldName('left');
const right = rangeClause.childForFieldName('right');
if (left) {
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
else if (c) this.walkValue(c, acc);
}
}
if (right) this.walkValue(right, acc);
return acc.finish();
}
/**
* Facts for a `switch t := i.(type)` head: `t` binds (a def) and the inspected
* value is used.
*/
typeSwitchHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const alias = stmt.childForFieldName('alias');
const value = stmt.childForFieldName('value');
if (alias) {
for (let i = 0; i < alias.namedChildCount; i++) {
const c = alias.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
if (value) this.walkValue(value, acc);
return acc.finish();
}
/** ENTRY-block facts for the receiver + parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
const recv = this.fnNode.childForFieldName('receiver');
if (recv) {
for (let i = 0; i < recv.namedChildCount; i++) {
const p = recv.namedChild(i);
if (p?.type !== 'parameter_declaration') continue;
const name = p.childForFieldName('name');
if (name) this.def(name, acc);
}
}
const params = this.fnNode.childForFieldName('parameters');
if (params) {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter_declaration' && p?.type !== 'variadic_parameter_declaration') {
continue;
}
for (let j = 0; j < p.namedChildCount; j++) {
const c = p.namedChild(j);
if (c?.type === 'identifier') this.def(c, acc);
}
}
}
return acc.defCount() ? acc.finish() : undefined;
}
private resolve(nameNode: SyntaxNode): number {
const name = nameNode.text;
const cached = this.nearestScopeCache.get(nameNode.id);
let startScope: Scope | null = cached ?? null;
if (!startScope) {
for (let p: SyntaxNode | null = nameNode; p; p = p.parent) {
const scope = this.scopeByNode.get(p.id) ?? this.nearestScopeCache.get(p.id);
if (scope) {
startScope = scope;
break;
}
if (p.id === this.fnId) {
startScope = this.root;
break;
}
}
}
for (let s: Scope | null = startScope; s; s = s.parent) {
const idx = s.table.get(name);
if (idx !== undefined) return idx;
}
let idx = this.synthetic.get(name);
if (idx === undefined) {
idx = this.bindings.length;
this.synthetic.set(name, idx);
this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true });
}
return idx;
}
private def(nameNode: SyntaxNode, acc: FactAccumulator): void {
if (nameNode.text === '_') return; // blank identifier 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--;
}
}
/** Strip parenthesized wrappers around an lvalue (`(x) = 1`). */
private unwrapLvalue(node: SyntaxNode): SyntaxNode {
let n = node;
let hops = 8;
while (n.type === 'parenthesized_expression' && hops-- > 0) {
const inner = n.namedChild(0);
if (!inner) break;
n = inner;
}
return n;
}
/** Def each identifier of an LHS `expression_list`; route non-identifiers to uses. */
private defLeftList(list: SyntaxNode, acc: FactAccumulator, alsoUse: boolean): void {
for (let i = 0; i < list.namedChildCount; i++) {
const c = list.namedChild(i);
if (!c) continue;
const lv = this.unwrapLvalue(c);
if (lv.type === 'identifier') {
this.def(lv, acc);
if (alsoUse) this.use(lv, acc); // compound assign (`+=`) reads too
} else {
// selector / index / pointer-deref target — uses only (root identifier).
this.walkValue(lv, acc);
}
}
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function literal — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'short_var_declaration': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
if (right) this.walkValue(right, acc);
if (left) {
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
return;
}
case 'var_declaration': {
for (const spec of this.varSpecs(node)) {
const value = spec.childForFieldName('value');
if (value) this.walkValue(value, acc);
if (value) {
for (let i = 0; i < spec.namedChildCount; i++) {
const c = spec.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
}
return;
}
case 'assignment_statement': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '=';
if (right) this.walkValue(right, acc);
if (left) this.defLeftList(left, acc, op !== '=');
return;
}
case 'inc_statement':
case 'dec_statement': {
// `x++` / `x--` — def AND use the lvalue when it's a plain identifier.
const operand = node.namedChild(0);
const lv = operand ? this.unwrapLvalue(operand) : null;
if (lv?.type === 'identifier') {
this.def(lv, acc);
this.use(lv, acc);
} else if (operand) {
this.walkValue(operand, acc);
}
return;
}
case 'binary_expression': {
const left = node.childForFieldName('left');
const right = node.childForFieldName('right');
const op = node.childForFieldName('operator')?.text ?? '';
if (left) this.walkValue(left, acc);
if (right) {
if (op === '&&' || op === '||') this.conditional(() => this.walkValue(right, acc));
else this.walkValue(right, acc);
}
return;
}
case 'selector_expression': {
// `a.b` — value read of the operand root only; the field name is not a
// scalar binding. Mirrors the TS member-read use semantics.
const operand = node.childForFieldName('operand');
if (operand) this.walkValue(operand, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
}
/** Ordered, deduplicating def/use collector for one statement record. */
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);
}
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,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}

View file

@ -0,0 +1,892 @@
/**
* Go CfgVisitor (#2195 U5, plan KTD2) — the highest-divergence C-family target.
*
* Walks a Go function / method / closure's tree-sitter AST and drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}, plus a def/use harvest ({@link GoHarvester}) for the
* reaching-defs / CDG solvers. Structured like the Java / C# visitors — a
* `visit_<node_type>` dispatch over the statement taxonomy, driving a
* per-function {@link ControlFlowContext} for labeled break/continue and the
* `defer` completion chain (Go's analogue of finally route-through).
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-go via the introspection probe before use (mandatory pre-step,
* KTD5). Go shapes pre-empted (verified by a real parse):
* - functions: `function_declaration`, `method_declaration` (field `receiver`),
* `func_literal` — all carry `parameters` + a `body` `block`.
* - `if_statement` fields `initializer`? / `condition` / `consequence` /
* `alternative`?; `else if` ⇒ `alternative` is a nested `if_statement`, plain
* `else` ⇒ `alternative` is a `block` (NO `else_clause` wrapper).
* - `for_statement` — Go's SINGLE loop keyword. `body` is a `block`; the first
* child is a `for_clause` (C-style, fields `initializer`?/`condition`?/`update`?)
* OR a `range_clause` (for-range, fields `left`?/`right`) OR a bare condition
* expression (while-style) OR ABSENT (`for {}` infinite). All four handled.
* - `expression_switch_statement` (fields `initializer`?/`value`?; children
* `expression_case` [field `value`=`expression_list`] / `default_case`) and
* `type_switch_statement` (fields `alias`?/`value`; children `type_case`
* [field `type`] / `default_case`) — cases do NOT fall through by default.
* - `fallthrough_statement` — EXPLICIT fallthrough to the next case (the
* opposite of C; modeled with a `fallthrough` edge).
* - `select_statement` (children `communication_case` [field `communication`=
* `receive_statement`/`send_statement`] / `default_case`).
* - `return_statement` (multiple-return via an `expression_list`),
* `break_statement` / `continue_statement` (BOTH may carry a `label_name`),
* `goto_statement` (`label_name` child), `labeled_statement` (field `label`=
* `label_name`).
* - `defer_statement` / `go_statement` — each wraps a `call_expression`.
*
* Edge-kind contract (matches the TS / Java / C# visitors — RD/CDG consume these):
* - if/else → `cond-true` / `cond-false`
* - for-loops (all four shapes) → `cond-true` / `loop-back` / `cond-false`
* - switch / select dispatch → `switch-case`; an explicit `fallthrough` → a
* `fallthrough` edge to the next case (Go cases otherwise do NOT fall through)
* - a `return` / normal completion threads through the active `defer` chain as
* `return` (first leg) + `finally-return` (each defer's completion leg)
* - return / break / continue → the matching terminator kind; a labeled
* `break outer;` / `continue outer;` targets the labeled frame, not the
* nearest one
* - straight-line → `seq`
*
* Go-specific modeling decisions (documented approximations — see the plan U5):
* - `defer f()`: deferred calls run at FUNCTION RETURN in LIFO order. Modeled as
* stacked completion legs (the {@link ControlFlowContext} finalizer machinery,
* Go's analogue of a `finally` route-through): each `defer` pushes a finalizer
* frame that stays active for the rest of the function, so every `return` AND
* the normal fall-off thread through ALL active defers innermost-first (LIFO).
* APPROXIMATION: a `defer` is registered at the point it executes, so a defer
* inside a not-yet-run branch is conservatively treated as active for the
* whole remaining function tail (Go would only run it if that branch ran). The
* panic/recover path is not modeled (documented gap).
* - `go f()`: spawns a goroutine — a SEPARATE flow. Decision (the simpler correct
* option): the `go` call is modeled as a normal straight-line statement in the
* CURRENT CFG and the spawned body is NOT followed inline. When the argument is
* a `go func(){…}()` closure, that `func_literal` is still collected as its OWN
* function by `isFunction` (the worker enumerates every function node), so its
* body gets a standalone CFG — nothing is dropped. A bare `go namedFn()` call's
* callee body lives in its own function CFG already. No edge is dropped, so no
* warning is logged for the common shapes.
* - `select {}` with no `default` BLOCKS forever; `for {}` (and `for cond {}`,
* and a `for {…}` with no `break`) may never terminate. Exactly as the sibling
* visitors emit a structural `header → loopExit` `cond-false` edge for
* `while(true)`, this visitor emits a structural exit-escape edge for EVERY
* for-loop shape AND for a `select` with no default, so EXIT stays
* reverse-reachable from every block — the post-dominator / CDG pass silently
* emits ZERO control-dependence for the function otherwise (CFG / REACHING_DEF
* survive; CDG goes to zero). This is the single highest-risk correctness
* property of the visitor.
*
* Classic hazards, handled explicitly (mirrors the Java / C# visitors):
* - loops allocate a dedicated loop-exit block so `break` has a target before
* the loop's successor is known; `continue` targets the header / update.
* - labeled `break outer;` / `continue outer;`: the label resolves against the
* frame of the construct it names (a labeled loop / switch / select), NOT the
* nearest enclosing frame. An UNLABELED break never targets a labeled-block
* frame (control-flow-context.ts enforces this).
* - `goto label;`: labels resolve within the function (forward AND backward);
* an unresolved `goto` (label in a sibling scope Go would reject, or malformed)
* routes to EXIT and logs, preserving single-exit.
*
* Known limitations:
* - `go`/goroutine inter-flow scheduling and channel happens-before are not
* modeled (each goroutine body is an independent CFG).
* - panic / recover: a `panic()` is a normal call here (no abnormal edge), and
* `recover()` inside a deferred closure is opaque; the panic-unwind path
* through defers is not modeled — documented gap, not faked.
* - Def/use harvest scope: see `go-harvest.ts` — selector / index / pointer
* writes are not scalar defs; `func_literal` bodies are opaque in both
* directions.
*
* 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 { FinalizerFrame } from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { GoHarvester } from './go-harvest.js';
/** Go node types that own a CFG-bearing function body. */
const GO_FUNCTION_TYPES = new Set([
'function_declaration',
'method_declaration',
'func_literal',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'for_statement',
'expression_switch_statement',
'type_switch_statement',
'select_statement',
'return_statement',
'break_statement',
'continue_statement',
'goto_statement',
'labeled_statement',
'fallthrough_statement',
'defer_statement',
'block',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function Go walk state. One instance per function so the
* {@link ControlFlowContext}, the `defer` finalizer chain, and the label tables
* are scoped to that function and never leak across functions.
*/
class GoCfgWalk {
private readonly cfc = new ControlFlowContext();
/** label name → its `labeled_statement` body's entry block (resolved on demand). */
private readonly labelBlocks = new Map<string, number>();
/** Pending gotos to a label not yet seen: label → list of source blocks. */
private readonly pendingGotos = new Map<string, number[]>();
/** Label(s) pending attachment to the NEXT pushed loop/switch/select frame. */
private pendingLabels: string[] = [];
/**
* Active `defer` finalizer frames in source (push) order. Innermost-LIFO is the
* REVERSE of this list — `finalizersForReturn()` already yields innermost-first,
* matching Go's LIFO defer execution. Frames stay active for the whole function
* tail and are drained once at the top-level walk's end.
*/
private readonly deferFrames: FinalizerFrame[] = [];
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: GoHarvester,
) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node. */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'block');
}
/** Visit a body that may be a `block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'block') return this.visitSeq(this.statementsOf(node));
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 (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
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 };
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'expression_switch_statement':
return this.visitExprSwitch(stmt);
case 'type_switch_statement':
return this.visitTypeSwitch(stmt);
case 'select_statement':
return this.visitSelect(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'goto_statement':
return this.visitGoto(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'fallthrough_statement':
return this.visitFallthrough(stmt);
case 'defer_statement':
return this.visitDefer(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] };
}
/**
* `return [expr…]` — threads through EVERY active `defer` (innermost-first =
* LIFO) before EXIT. `finalizersForReturn()` yields the active finalizer frames
* innermost-first, which is exactly Go's defer execution order.
*/
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: [] };
}
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: [] };
}
/** The trailing `label_name` of a `break`/`continue`, if any. */
private jumpLabel(stmt: SyntaxNode): string | undefined {
const id = stmt.namedChildren.find((c) => c.type === 'label_name');
return id?.text;
}
/** `goto label;` — route to the label block if known, else defer / EXIT. */
private visitGoto(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = stmt.namedChildren.find((c) => c.type === 'label_name')?.text;
if (label === undefined) {
this.builder.edge(idx, this.builder.exitIndex, 'seq'); // malformed — single-exit
return { entry: idx, exits: [] };
}
const target = this.labelBlocks.get(label);
if (target !== undefined) {
this.builder.edge(idx, target, 'seq'); // backward goto: label already built
} else {
const list = this.pendingGotos.get(label);
if (list) list.push(idx);
else this.pendingGotos.set(label, [idx]);
}
return { entry: idx, exits: [] };
}
/**
* `label: <statement>` — the label names the construct it directly wraps. For a
* loop / switch / select we forward the label so its pushed frame carries it
* (`break outer;` then resolves to it); for any other labeled statement we
* register the label block so a `goto label` reaches it.
*/
private visitLabeled(stmt: SyntaxNode): SeqResult {
const labelNode = stmt.childForFieldName('label');
const label = labelNode?.text;
const body = stmt.namedChildren.find((c) => c.id !== labelNode?.id && c.type !== 'comment') ?? null;
if (label !== undefined && body && this.isBreakableStatement(body)) {
// Forward the label to the loop/switch/select frame this statement pushes.
this.pendingLabels = [...this.pendingLabels, label];
const res = this.visitStmt(body);
this.registerLabel(label, res?.entry ?? this.synthLabelBlock(stmt));
return res ?? { entry: this.labelBlocks.get(label)!, exits: [] };
}
const res = this.visitBody(body);
if (label !== undefined) {
const entry = res?.entry ?? this.synthLabelBlock(stmt);
this.registerLabel(label, entry);
if (!res) return { entry, exits: [entry] };
}
return res;
}
private synthLabelBlock(stmt: SyntaxNode): number {
return this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
}
/** Register a resolved label block and wire any forward gotos that waited on it. */
private registerLabel(label: string, entry: number): void {
this.labelBlocks.set(label, entry);
const pending = this.pendingGotos.get(label);
if (pending) {
for (const from of pending) this.builder.edge(from, entry, 'seq');
this.pendingGotos.delete(label);
}
}
private isBreakableStatement(node: SyntaxNode): boolean {
return (
node.type === 'for_statement' ||
node.type === 'expression_switch_statement' ||
node.type === 'type_switch_statement' ||
node.type === 'select_statement'
);
}
/** Take and clear the labels queued by an enclosing `labeled_statement`. */
private takeLabels(): string[] {
const labels = this.pendingLabels;
this.pendingLabels = [];
return labels;
}
/**
* `fallthrough` — EXPLICIT transfer to the next case body (Go cases do not fall
* through implicitly). Recorded as a marker block; the enclosing switch wires
* the `fallthrough` edge from a case body whose dangling exit is this block.
* It carries no normal exit (control leaves to the next case).
*/
private visitFallthrough(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.fallthroughBlocks.add(idx);
return { entry: idx, exits: [idx] };
}
/** Blocks that are an explicit `fallthrough` terminator (per-function). */
private readonly fallthroughBlocks = new Set<number>();
/**
* `defer f()` — register the deferred call as a finalizer frame that stays
* active for the rest of the function tail. Every later `return` (and the
* normal fall-off) threads through it; LIFO across multiple defers falls out of
* `finalizersForReturn()` yielding innermost-first. The defer's call expression
* carries its def/use facts. The frame is NOT popped here — the top-level walk
* drains all defer frames once at function end.
*/
private visitDefer(stmt: SyntaxNode): TraversalResult {
// A single facts-only block is created for the deferred call body; it is the
// finalizer entry that completion legs route through.
const call = stmt.namedChildren.find((c) => c.type !== 'comment') ?? stmt;
const deferBlock = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(call),
);
const frame = this.cfc.pushFinalizer(deferBlock);
this.deferFrames.push(frame);
// The `defer` statement itself is a no-op at its source position — control
// falls straight through to the next statement (the deferred body only runs
// at function exit, modeled by the completion-leg threading). We therefore
// return a SEPARATE marker block as the in-line position so the deferred
// block stays out of the straight-line flow.
const marker = this.builder.newBlock(startLineOf(stmt), startLineOf(stmt), '');
return { entry: marker, exits: [marker] };
}
/**
* Drain the active `defer` chain at function end. Each deferred block is a
* single block, so its "exit" is itself; the chain runs innermost-first (LIFO,
* matching Go). Returns the block set control reaches AFTER the whole defer
* chain runs (to be wired to EXIT by the caller), or `normalExits` unchanged
* when there are no defers.
*
* Two completion sources converge here:
* - `return` statements that crossed these frames registered pending legs via
* `wireJumpThroughFinalizers`; {@link drainFinalizerPending} wires them
* (return → defer[0]; defer[i] → defer[i+1]; defer[last] → EXIT).
* - the function's NORMAL fall-off is threaded explicitly here through the
* same chain (`return` first leg, `finally-return` inter-defer legs); the
* builder de-dups, so legs shared with the return paths collapse.
*/
finishDefers(normalExits: readonly number[]): readonly number[] {
if (this.deferFrames.length === 0) return normalExits;
// Innermost-first (LIFO): the LAST-registered defer runs first.
const lifo = [...this.deferFrames].reverse();
// Pop the frames off the active stack and drain any pending completion legs
// the return/break handlers registered while these frames were active.
for (let i = 0; i < lifo.length; i++) this.cfc.pop();
for (const frame of lifo) drainFinalizerPending(this.builder, frame, [frame.entry]);
// Thread the normal fall-off through the chain innermost-first: the first leg
// keeps the bare `return` kind (the "kind ⟹ source terminator" invariant), the
// inter-defer legs are `finally-return` completion edges.
if (normalExits.length > 0) {
this.builder.connect(normalExits, lifo[0].entry, 'return');
for (let i = 0; i + 1 < lifo.length; i++) {
this.builder.edge(lifo[i].entry, lifo[i + 1].entry, 'finally-return');
}
}
// After the outermost defer runs, control reaches EXIT.
return [lifo[lifo.length - 1].entry];
}
/**
* Route any forward `goto`s whose label never appeared in the function to EXIT
* (single-exit preserved) and log them so a dropped jump is never silent (R4).
* Called once after the body walk.
*/
flushGotos(builder: CfgBuilder): void {
for (const [label, froms] of this.pendingGotos) {
// eslint-disable-next-line no-console
console.warn(
`[cfg] Go: unresolved goto label "${label}" routed to EXIT (${froms.length} site(s))`,
);
for (const from of froms) builder.edge(from, builder.exitIndex, 'seq');
}
this.pendingGotos.clear();
}
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const init = stmt.childForFieldName('initializer');
// The header block carries the (optional) initializer's facts AND the
// condition's facts — both evaluate before the branch.
const header = this.builder.newBlock(
init ? startLineOf(init) : startLineOf(stmt),
endLineOf(cond),
init ? `${init.text}; ${cond.text}` : cond.text,
'normal',
this.harvest.facts(cond),
);
if (init) this.builder.attachFacts(header, this.harvest.facts(init));
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('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
}
// No `else_clause` wrapper in Go: `alternative` is the else `block` or the
// nested `if_statement` of an `else if` chain directly.
const elseNode = stmt.childForFieldName('alternative');
if (elseNode) {
const elseRes = this.visitBody(elseNode);
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)] };
}
/**
* `for_statement` — Go's single loop keyword covers all four shapes:
* 1. `for clause { }` (`for_clause`: init?/cond?/update? — C-style)
* 2. `for range x { }` (`range_clause`)
* 3. `for cond { }` (a bare condition expression — while-style)
* 4. `for { }` (no header child — infinite)
*/
private visitFor(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const head = this.forHeadChild(stmt);
if (head?.type === 'for_clause') return this.visitForClause(stmt, head, labels);
if (head?.type === 'range_clause') return this.visitForRange(stmt, head, labels);
// While-style (bare condition) or infinite (`for {}`): `head` is the
// condition expression (or undefined).
return this.visitForCond(stmt, head ?? undefined, labels);
}
/** The header child of a `for_statement` (for_clause / range_clause / cond), or undefined. */
private forHeadChild(stmt: SyntaxNode): SyntaxNode | undefined {
const body = stmt.childForFieldName('body');
return stmt.namedChildren.find((c) => c.id !== body?.id && c.type !== 'comment');
}
private visitForClause(stmt: SyntaxNode, clause: SyntaxNode, labels: string[]): TraversalResult {
const init = clause.childForFieldName('initializer');
const cond = clause.childForFieldName('condition');
const incr = clause.childForFieldName('update');
const header = this.builder.newBlock(
cond ? startLineOf(cond) : startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for{}',
'normal',
cond ? this.harvest.facts(cond) : undefined,
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(
startLineOf(incr),
endLineOf(incr),
incr.text,
'normal',
this.harvest.facts(incr),
);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.cfc.pushLoop(incrBlock, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
if (!incr) this.builder.edge(header, header, 'loop-back');
}
// Structural exit edge — `for ;; {}` (no condition) still keeps EXIT
// reverse-reachable so CDG is not silently skipped for the function.
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForRange(stmt: SyntaxNode, clause: SyntaxNode, labels: string[]): TraversalResult {
// Header carries the range head facts (loop vars are defs, the iterated
// expression is a use).
const header = this.builder.newBlock(
startLineOf(stmt),
endLineOf(clause),
clause.text,
'normal',
this.harvest.rangeHeadFacts(clause),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, labels);
const body = this.visitBody(this.bodyBlockOf(stmt));
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');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitForCond(
stmt: SyntaxNode,
cond: SyntaxNode | undefined,
labels: string[],
): TraversalResult {
const header = this.builder.newBlock(
cond ? startLineOf(cond) : startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for{}',
'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(this.bodyBlockOf(stmt));
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 `for {}` body re-tests
}
// Always emit the structural exit edge — even `for {}` (infinite, no
// condition) and `for cond {}` keep EXIT reverse-reachable for the CDG pass.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitExprSwitch(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const value = stmt.childForFieldName('value');
const init = stmt.childForFieldName('initializer');
const dispatch = this.builder.newBlock(
startLineOf(stmt),
value ? endLineOf(value) : startLineOf(stmt),
value ? value.text : 'switch{}',
'normal',
value ? this.harvest.facts(value) : undefined,
);
if (init) this.builder.attachFacts(dispatch, this.harvest.facts(init));
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'expression_case' || c.type === 'default_case',
);
// Each `expression_case`'s test value(s) evaluate before the body — harvest
// their uses CONDITIONALLY onto the dispatch block (a later case only tests
// when earlier cases didn't match).
for (const c of cases) {
if (c.type !== 'expression_case') continue;
const test = c.childForFieldName('value');
if (test) this.builder.attachFacts(dispatch, this.harvest.factsConditional(test));
}
const result = this.buildCases(dispatch, switchExit, cases, (c) =>
this.exprCaseBody(c),
);
this.cfc.pop();
return result;
}
private visitTypeSwitch(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const dispatch = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.typeSwitchHeaderText(stmt),
'normal',
this.harvest.typeSwitchHeadFacts(stmt),
);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'type_case' || c.type === 'default_case',
);
const result = this.buildCases(dispatch, switchExit, cases, (c) =>
this.typeCaseBody(c),
);
this.cfc.pop();
return result;
}
private visitSelect(stmt: SyntaxNode): TraversalResult {
const labels = this.takeLabels();
const dispatch = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
'select',
);
const selectExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(selectExit, labels);
const cases = stmt.namedChildren.filter(
(c) => c.type === 'communication_case' || c.type === 'default_case',
);
// A comm case's communication clause (`v := <-ch` / `ch <- x`) evaluates as
// part of dispatch — harvest its facts onto the dispatch block.
for (const c of cases) {
if (c.type !== 'communication_case') continue;
const comm = c.childForFieldName('communication');
if (comm) this.builder.attachFacts(dispatch, this.harvest.facts(comm));
}
const hasDefault = cases.some((c) => c.type === 'default_case');
const result = this.buildCases(dispatch, selectExit, cases, (c) =>
this.commCaseBody(c),
);
// A `select` with NO default BLOCKS until a case is ready — and a `select {}`
// with no cases at all blocks forever. Either way EXIT must stay
// reverse-reachable: emit a structural escape edge dispatch → selectExit so
// the CDG pass is not silently skipped for the function.
if (!hasDefault) this.builder.edge(dispatch, selectExit, 'switch-case');
this.cfc.pop();
return result;
}
/**
* Shared dispatch builder for expression-switch / type-switch / select. Cases
* do NOT fall through by default (the opposite of C); an EXPLICIT
* `fallthrough` block in a case body spills into the NEXT case instead of the
* switch exit. `default_case` is the no-match target; without one, the dispatch
* also reaches the switch exit directly (no-match path).
*/
private buildCases(
dispatch: number,
switchExit: number,
cases: SyntaxNode[],
bodyOf: (c: SyntaxNode) => SyntaxNode[],
): TraversalResult {
const caseResults = cases.map((c) => this.visitSeq(bodyOf(c)));
const hasDefault = cases.some((c) => c.type === 'default_case');
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
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 bodies rejoin AFTER the switch (no implicit fallthrough), UNLESS the
// body ends in an explicit `fallthrough`, which spills into the next case.
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
for (const ex of res.exits) {
if (this.fallthroughBlocks.has(ex)) {
this.builder.edge(ex, fallTarget, 'fallthrough');
} else {
this.builder.edge(ex, switchExit, 'seq');
}
}
}
return { entry: dispatch, exits: [switchExit] };
}
/** `expression_case` body statements (everything but the `value` test). */
private exprCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
/** `type_case` body statements (everything but the `type` field children). */
private typeCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const typeIds = new Set<number>();
// All `type` field children (a type_case may list several types).
for (let i = 0; i < caseNode.childCount; i++) {
if (caseNode.fieldNameForChild(i) === 'type') {
const c = caseNode.child(i);
if (c) typeIds.add(c.id);
}
}
return caseNode.namedChildren.filter((c) => !typeIds.has(c.id) && c.type !== 'comment');
}
/** `communication_case` body statements (everything but the `communication` clause). */
private commCaseBody(caseNode: SyntaxNode): SyntaxNode[] {
const comm = caseNode.childForFieldName('communication');
return caseNode.namedChildren.filter((c) => c.id !== comm?.id && c.type !== 'comment');
}
private typeSwitchHeaderText(stmt: SyntaxNode): string {
const alias = stmt.childForFieldName('alias')?.text;
const value = stmt.childForFieldName('value')?.text ?? '';
return alias ? `switch ${alias} := ${value}.(type)` : `switch ${value}.(type)`;
}
}
/** Build the CFG for one Go function node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!GO_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
const body = fnNode.childForFieldName('body');
if (!body || body.type !== 'block') return undefined; // forward decl / interface method
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new GoHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
const walk = new GoCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
builder.edge(builder.entryIndex, res ? res.entry : builder.exitIndex, 'seq');
// Normal fall-off threads through the active `defer` chain (LIFO) → EXIT.
const normalExits = res ? res.exits : [builder.entryIndex];
const afterDefers = walk.finishDefers(normalExits);
builder.connect(afterDefers, builder.exitIndex, 'seq');
walk.flushGotos(builder);
return builder.finish(harvest.table());
} 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] Go buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a Go function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return GO_FUNCTION_TYPES.has(node.type);
}
/** The Go CFG visitor. */
export function createGoCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { GO_FUNCTION_TYPES };

View file

@ -11,6 +11,7 @@
import { SupportedLanguages } from 'gitnexus-shared';
import { createClassExtractor } from '../class-extractors/generic.js';
import { goClassConfig } from '../class-extractors/configs/go.js';
import { createGoCfgVisitor } from '../cfg/visitors/go.js';
import { defineLanguage } from '../language-provider.js';
import { typeConfig as goConfig } from '../type-extractors/go.js';
import { goExportChecker } from '../export-detection.js';
@ -141,6 +142,7 @@ export const goProvider = defineLanguage({
// ── RFC #909 Ring 3: scope-based resolution hooks ──────────
emitScopeCaptures: emitGoScopeCaptures,
cfgVisitor: createGoCfgVisitor(),
interpretImport: interpretGoImport,
interpretTypeBinding: interpretGoTypeBinding,
bindingScopeFor: goBindingScopeFor,

View file

@ -0,0 +1,189 @@
// Go CFG hazard fixture (#2195 U5). Exercises every control-flow construct the
// Go visitor models, including the EXIT-reachability hazards (`for {}` /
// `select {}` with no default) the CDG soundness gate depends on. Used by the
// worker-mode PDG pipeline assertions and the cross-language CFG snapshot.
package hazards
import "errors"
// ifWithInitializer — `if init; cond { } else if { } else { }`.
func ifWithInitializer(x int) int {
if v := x * 2; v > 0 {
return v
} else if v < 0 {
return -v
} else {
return 0
}
}
// forClause — C-style three-clause for, with continue/break.
func forClause(n int) int {
sum := 0
for i := 0; i < n; i++ {
if i%2 == 0 {
continue
}
if i > 100 {
break
}
sum += i
}
return sum
}
// forWhile — condition-only (while-style) loop.
func forWhile(x int) int {
for x > 0 {
x--
}
return x
}
// forRange — for-range over a slice; multi-return loop vars.
func forRange(xs []int) int {
total := 0
for k, v := range xs {
total += k + v
}
return total
}
// forInfinite — `for {}` with no condition: NON-terminating shape. EXIT must
// stay reverse-reachable (structural exit edge) or CDG silently goes to zero.
func forInfinite(ch chan int) {
for {
v := <-ch
if v < 0 {
handle(v)
}
}
}
// exprSwitch — expression switch; cases do NOT fall through by default.
func exprSwitch(x int) string {
switch x {
case 1:
return "one"
case 2, 3:
return "few"
default:
return "many"
}
}
// explicitFallthrough — Go's EXPLICIT fallthrough (opposite of C).
func explicitFallthrough(x int) int {
r := 0
switch x {
case 1:
r = 1
fallthrough
case 2:
r += 2
default:
r = -1
}
return r
}
// typeSwitch — type switch with an alias binding.
func typeSwitch(i interface{}) string {
switch t := i.(type) {
case int:
return useInt(t)
case string, []byte:
return "str"
default:
return "other"
}
}
// selectDispatch — select across communication cases (with default).
func selectDispatch(ch chan int, out chan int) {
select {
case v := <-ch:
out <- v
case out <- 0:
sent()
default:
none()
}
}
// selectBlocking — `select {}` with NO default blocks forever: EXIT must stay
// reverse-reachable (structural escape edge) or CDG silently goes to zero.
func selectBlocking(x bool) {
if x {
setup()
}
select {}
}
// deferLifo — deferred calls run at function return in LIFO order.
func deferLifo(path string) (err error) {
defer first()
defer second()
f, e := open(path)
if e != nil {
return errors.New("open failed")
}
use(f)
return nil
}
// labeledJumps — labeled break / continue / goto.
func labeledJumps(grid [][]int) int {
found := -1
outer:
for i := 0; i < len(grid); i++ {
for j := 0; j < len(grid[i]); j++ {
if grid[i][j] == 0 {
continue outer
}
if grid[i][j] < 0 {
found = i
break outer
}
}
}
if found < 0 {
goto done
}
report(found)
done:
return found
}
// multiReturn — `a, b := f()` defines both names.
func multiReturn() int {
a, b := pair()
return a + b
}
// goroutine — `go func(){…}()` spawns a separate flow (not followed inline).
func goroutine(jobs chan int) {
go func() {
for j := range jobs {
process(j)
}
}()
go worker(1)
after()
}
// Helper stubs so the fixture parses as a complete package.
func handle(v int) {}
func useInt(t int) string { return "" }
func sent() {}
func none() {}
func first() {}
func second() {}
func open(p string) (int, error) { return 0, nil }
func use(x int) {}
func report(i int) {}
func pair() (int, int) { return 1, 2 }
func process(j int) {}
func worker(n int) {}
func after() {}
func setup() {}

View file

@ -0,0 +1,426 @@
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { createGoCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/go.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js';
import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js';
// U5 — the Go CfgVisitor, one hazard per test (KTD5: real-parser regression,
// NOT snapshot-pinning). Each fixture's distinctive statement text (step(),
// done(), one(), …) lets us locate the block for a region by text and assert the
// control-flow topology around it. Go is the highest-divergence target — the
// EXIT-reachability regressions for `for {}` / `select {}` are load-bearing.
const goGrammar = createRequire(import.meta.url)('tree-sitter-go') as Parameters<
typeof makeCfgHarness
>[0];
const go: CfgHarness = makeCfgHarness(goGrammar, createGoCfgVisitor(), 'fixture.go');
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);
/** 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 hasDef = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx)));
const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx)));
const hasMayDef = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx)));
/** Wrap a Go function body in a minimal compilable package. */
const pkg = (src: string): string => `package main\n${src}\n`;
describe('Go CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT (seq)', () => {
const cfg = go.cfgOf(pkg(`func 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 = go.cfgOf(pkg(`func f() {}`));
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('function, method, and func literal are all CFG-bearing', () => {
const cfgs = go.cfgsOf(
pkg(`func f() { x() }\nfunc (r *T) M() { y() }\nvar g = func() { z() }`),
);
expect(cfgs.length).toBeGreaterThanOrEqual(3); // f, M, the func literal
for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('forward declaration / no body → undefined, never throws', () => {
const root = go.parse(pkg(`type I interface { M() }`));
const fns = go.collectFunctions(root);
for (const fn of fns) {
expect(() => createGoCfgVisitor().buildFunctionCfg(fn, 'f.go')).not.toThrow();
}
});
});
describe('Go CfgVisitor — if', () => {
it('if with initializer: init+cond on the header, both arms reach the join', () => {
const cfg = go.cfgOf(
pkg(`func f() { if v := compute(); v > 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);
// the initializer `v := compute()` defines v and the condition uses it.
const v = bindingIdx(cfg, 'v');
expect(hasDef(cfg, v)).toBe(true);
expect(hasUse(cfg, v)).toBe(true);
});
it('else if chains through the nested alternative (no else_clause wrapper)', () => {
const cfg = go.cfgOf(
pkg(`func f(x int) { if x > 0 { a() } else if x < 0 { b() } else { c() } }`),
);
expect(reaches(cfg, block(cfg, 'a()'), cfg.exitIndex)).toBe(true);
expect(reaches(cfg, block(cfg, 'b()'), cfg.exitIndex)).toBe(true);
expect(reaches(cfg, block(cfg, 'c()'), cfg.exitIndex)).toBe(true);
});
});
describe('Go CfgVisitor — for (Go single loop keyword, four shapes)', () => {
it('for_clause (C-style): init once, condition header, back-edge through update', () => {
const cfg = go.cfgOf(pkg(`func f(n int) { for i := 0; i < n; i++ { step() }; done() }`));
const init = block(cfg, 'i := 0');
const header = block(cfg, 'i < n');
const incr = block(cfg, 'i++');
const body = block(cfg, 'step()');
expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: init, kind: 'seq' });
expect(reaches(cfg, body, incr)).toBe(true);
expect(cfg.edges).toContainEqual({ from: incr, to: header, kind: 'loop-back' });
expect(reaches(cfg, header, block(cfg, 'done()'))).toBe(true);
});
it('for cond {} (while-style): header + back-edge + exit', () => {
const cfg = go.cfgOf(pkg(`func f(x int) { for x > 0 { step() }; done() }`));
const header = block(cfg, 'x > 0');
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('for range: header + body + loop-back; loop vars are defs, source a use', () => {
const cfg = go.cfgOf(pkg(`func f(xs []int) { for k, v := range xs { use(k, v) }; done() }`));
const body = block(cfg, 'use(k, v)');
expect(edgeKinds(cfg).has('cond-true')).toBe(true);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to;
expect(header).toBeDefined();
expect(reaches(cfg, header!, block(cfg, 'done()'))).toBe(true);
expect(hasDef(cfg, bindingIdx(cfg, 'k'))).toBe(true);
expect(hasDef(cfg, bindingIdx(cfg, 'v'))).toBe(true);
expect(hasUse(cfg, bindingIdx(cfg, 'xs'))).toBe(true);
});
it('for {} (infinite) keeps EXIT reverse-reachable AND emits CDG > 0', () => {
const cfg = go.cfgOf(pkg(`func f(x bool) { for { if x { g() } } }`));
expect(edgeKinds(cfg).has('cond-false')).toBe(true);
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
// The inner `if` is a real control point — CDG must be non-empty (it is only
// computed when EXIT stays reverse-reachable).
expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0);
});
});
describe('Go CfgVisitor — switch (no implicit fallthrough)', () => {
it('expression_switch cases do NOT fall through by default', () => {
const cfg = go.cfgOf(
pkg(`func f(x int) {
switch x {
case 1:
one()
case 2:
two()
default:
other()
}
after()
}`),
);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
// each case body rejoins after the switch...
expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'after()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'two()'), block(cfg, 'after()'))).toBe(true);
// ...but case 1 does NOT fall into case 2 (Go has no implicit fallthrough).
expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'two()'))).toBe(false);
});
it('explicit fallthrough_statement adds a fallthrough edge to the next case', () => {
const cfg = go.cfgOf(
pkg(`func f(x int) {
switch x {
case 1:
one()
fallthrough
case 2:
two()
}
after()
}`),
);
expect(edgeKinds(cfg).has('fallthrough')).toBe(true);
// case 1 (via fallthrough) reaches case 2's body.
expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'two()'))).toBe(true);
});
it('type_switch dispatches across type cases', () => {
const cfg = go.cfgOf(
pkg(`func f(i interface{}) {
switch t := i.(type) {
case int:
useInt(t)
case string:
useStr(t)
default:
other()
}
after()
}`),
);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
expect(reaches(cfg, block(cfg, 'useInt(t)'), block(cfg, 'after()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'useStr(t)'), block(cfg, 'after()'))).toBe(true);
// the type-switch alias `t` is a def on the dispatch header.
expect(hasDef(cfg, bindingIdx(cfg, 't'))).toBe(true);
});
});
describe('Go CfgVisitor — select', () => {
it('select dispatches across communication cases (no implicit fallthrough)', () => {
const cfg = go.cfgOf(
pkg(`func f(ch chan int) {
select {
case v := <-ch:
use(v)
case ch <- 1:
sent()
default:
none()
}
after()
}`),
);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
expect(reaches(cfg, block(cfg, 'use(v)'), block(cfg, 'after()'))).toBe(true);
expect(reaches(cfg, block(cfg, 'sent()'), block(cfg, 'after()'))).toBe(true);
// comm case 1 does not fall into comm case 2.
expect(reaches(cfg, block(cfg, 'use(v)'), block(cfg, 'sent()'))).toBe(false);
});
it('select {} with no default keeps EXIT reverse-reachable AND emits CDG > 0', () => {
// The CDG probe required by the plan: a `select {}` blocks forever, so EXIT
// must stay reverse-reachable or CDG is silently skipped for the function.
const cfg = go.cfgOf(pkg(`func f(x bool) { if x { g() }; select {} }`));
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0);
});
it('bare select {} keeps EXIT reverse-reachable on its own', () => {
const cfg = go.cfgOf(pkg(`func f() { select {} }`));
expect(isExitReachableFromAllBlocks(cfg)).toBe(true);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
});
describe('Go CfgVisitor — defer (LIFO completion legs)', () => {
it('defer body runs on the normal exit path', () => {
const cfg = go.cfgOf(pkg(`func f() { defer cleanup(); work() }`));
const cleanup = block(cfg, 'defer cleanup()');
const work = block(cfg, 'work()');
// normal completion of work threads through the deferred cleanup → EXIT.
expect(reaches(cfg, work, cleanup)).toBe(true);
expect(reaches(cfg, cleanup, cfg.exitIndex)).toBe(true);
});
it('defer runs on a return path too (return threads through the defer)', () => {
const cfg = go.cfgOf(pkg(`func f(x bool) int { defer cleanup(); if x { return 1 }; return 2 }`));
const cleanup = block(cfg, 'defer cleanup()');
expect(reaches(cfg, block(cfg, 'return 1'), cleanup)).toBe(true);
expect(reaches(cfg, block(cfg, 'return 2'), cleanup)).toBe(true);
expect(reaches(cfg, cleanup, cfg.exitIndex)).toBe(true);
});
it('two defers run LIFO: the last-registered runs first', () => {
const cfg = go.cfgOf(pkg(`func f() { defer first(); defer second(); work() }`));
const first = block(cfg, 'defer first()');
const second = block(cfg, 'defer second()');
// LIFO: `second` runs before `first` on the exit path.
expect(reaches(cfg, second, first)).toBe(true);
expect(reaches(cfg, first, cfg.exitIndex)).toBe(true);
// the deferred call facts are present (cleanup runs at exit).
expect(edgeKinds(cfg).has('finally-return')).toBe(true);
});
});
describe('Go CfgVisitor — labeled break / continue / goto', () => {
it('labeled break exits the OUTER loop from a nested loop', () => {
const cfg = go.cfgOf(
pkg(`func f() {
outer:
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if cond() { break outer }
}
}
done()
}`),
);
expect(edgeKinds(cfg).has('break')).toBe(true);
const brk = block(cfg, 'break outer');
// the labeled break reaches the post-loop `done()`, skipping both loops.
expect(reaches(cfg, brk, block(cfg, 'done()'))).toBe(true);
});
it('labeled continue re-tests the OUTER loop', () => {
const cfg = go.cfgOf(
pkg(`func f() {
outer:
for i := 0; i < 10; i++ {
for j := 0; j < 10; j++ {
if cond() { continue outer }
}
}
}`),
);
expect(edgeKinds(cfg).has('continue')).toBe(true);
const outerHeader = block(cfg, 'i < 10');
expect(reaches(cfg, block(cfg, 'continue outer'), outerHeader)).toBe(true);
});
it('goto reaches a forward label', () => {
const cfg = go.cfgOf(pkg(`func f(x bool) { if x { goto end }; work(); end: done() }`));
const gotoB = block(cfg, 'goto end');
const label = block(cfg, 'done()');
expect(reaches(cfg, gotoB, label)).toBe(true);
expect(reachable(cfg, block(cfg, 'work()'))).toBe(true);
});
});
describe('Go CfgVisitor — go statement (spawned flow not followed inline)', () => {
it('go func(){…}() does not throw; the closure body builds its own CFG', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const cfgs = go.cfgsOf(pkg(`func f() { go func() { run() }(); go worker(1); after() }`));
// f plus the spawned closure = at least 2 CFGs.
expect(cfgs.length).toBeGreaterThanOrEqual(2);
for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
// The `go` call is a normal straight-line statement in f — `after()` runs.
const f = cfgs[0];
expect(reachable(f, block(f, 'after()'))).toBe(true);
// No dropped edge → no warning for the common shapes.
expect(warn).not.toHaveBeenCalled();
} finally {
warn.mockRestore();
}
});
});
describe('Go CfgVisitor — def/use harvest', () => {
it('a, b := f() defines BOTH a and b (multiple-return assignment)', () => {
const cfg = go.cfgOf(pkg(`func f() { a, b := load(); use(a, b) }`));
expect(hasDef(cfg, bindingIdx(cfg, 'a'))).toBe(true);
expect(hasDef(cfg, bindingIdx(cfg, 'b'))).toBe(true);
expect(hasUse(cfg, bindingIdx(cfg, 'a'))).toBe(true);
expect(hasUse(cfg, bindingIdx(cfg, 'b'))).toBe(true);
});
it('x := …; use(x) → def then use', () => {
const cfg = go.cfgOf(pkg(`func f(a int, b int) { x := a + b; use(x) }`));
const x = bindingIdx(cfg, 'x');
expect(hasDef(cfg, x)).toBe(true);
expect(hasUse(cfg, x)).toBe(true);
});
it('compound assignment (x += 1) reads AND writes the lvalue', () => {
const cfg = go.cfgOf(pkg(`func f() { x := 1; x += 3 }`));
const x = bindingIdx(cfg, 'x');
expect(hasDef(cfg, x)).toBe(true);
expect(hasUse(cfg, x)).toBe(true);
});
it('inc_statement (x++) reads and writes', () => {
const cfg = go.cfgOf(pkg(`func f() { x := 0; x++ }`));
const x = bindingIdx(cfg, 'x');
expect(hasDef(cfg, x)).toBe(true);
expect(hasUse(cfg, x)).toBe(true);
});
it('var with initializer is a def; bare var (no value) is not', () => {
const cfg = go.cfgOf(pkg(`func f() { var a = 1; var b int; use(a, b) }`));
const a = bindingIdx(cfg, 'a');
expect(hasDef(cfg, a)).toBe(true);
// `var b int` writes nothing at runtime → `b` is a use-only synthetic-ish
// binding (no def fact); the use(b) reads it.
expect(hasUse(cfg, bindingIdx(cfg, 'b'))).toBe(true);
});
it('selector write (obj.f = …) is NOT a scalar def — root identifier is a use', () => {
const cfg = go.cfgOf(pkg(`func f(obj *T) { obj.field = 1; use(obj) }`));
// `obj` is used (its field is written), never a scalar def for `field`.
expect(hasUse(cfg, bindingIdx(cfg, 'obj'))).toBe(true);
});
it('a && (cond) short-circuit right operand uses are recorded', () => {
// Go has no in-expression assignment, so the conditional machinery is
// exercised via the may-def path of a switch case test; here we just confirm
// the && right operand is walked (its identifier is a use).
const cfg = go.cfgOf(pkg(`func f(a bool, b bool) { if a && b { g() } }`));
expect(hasUse(cfg, bindingIdx(cfg, 'b'))).toBe(true);
});
it('switch case test is harvested as a conditional (may-def-capable) use', () => {
const cfg = go.cfgOf(pkg(`func f(x int, k int) { switch x { case k: a() } }`));
// `k` (the case test) is used on the dispatch block; the conditional context
// means any def there would be a may-def (none here, but the path is live).
expect(hasUse(cfg, bindingIdx(cfg, 'k'))).toBe(true);
void hasMayDef; // may-def path covered structurally by the conditional walk
});
});
describe('Go CfgVisitor — functionStartColumn', () => {
it('two same-line funcs get distinct functionStartColumn', () => {
const cfgs = go.cfgsOf(pkg(`func A() { x() }; func B() { y() }`));
expect(cfgs).toHaveLength(2);
expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine);
expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn);
});
});