diff --git a/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts new file mode 100644 index 000000000..880b6c2db --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts @@ -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; +} + +export class GoHarvester { + private readonly bindings: BindingEntry[] = []; + private readonly scopeByNode = new Map(); + private readonly root: Scope = { parent: null, table: new Map() }; + private readonly synthetic = new Map(); + private readonly fnId: number; + /** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */ + private readonly nearestScopeCache = new Map(); + /** >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(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + + constructor(private readonly line: number) {} + + addDef(idx: number): void { + if (this.defSeen.has(idx)) return; + this.defSeen.add(idx); + this.defs.push(idx); + } + + 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 } : {}), + }; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/go.ts b/gitnexus/src/core/ingestion/cfg/visitors/go.ts new file mode 100644 index 000000000..b4b0c58db --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/go.ts @@ -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_` 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(); + /** Pending gotos to a label not yet seen: label → list of source blocks. */ + private readonly pendingGotos = new Map(); + /** 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: ` — 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(); + + /** + * `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(); + // 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 { + return { buildFunctionCfg, isFunction }; +} + +export { GO_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/go.ts b/gitnexus/src/core/ingestion/languages/go.ts index 2b9b85a52..039a04693 100644 --- a/gitnexus/src/core/ingestion/languages/go.ts +++ b/gitnexus/src/core/ingestion/languages/go.ts @@ -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, diff --git a/gitnexus/test/integration/cfg/fixtures/go-hazards.go b/gitnexus/test/integration/cfg/fixtures/go-hazards.go new file mode 100644 index 000000000..b04887551 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/go-hazards.go @@ -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() {} diff --git a/gitnexus/test/unit/cfg/go-visitor.test.ts b/gitnexus/test/unit/cfg/go-visitor.test.ts new file mode 100644 index 000000000..9a217f447 --- /dev/null +++ b/gitnexus/test/unit/cfg/go-visitor.test.ts @@ -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 => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** 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); + }); +});