mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
6dbe7373ff
commit
fff2c1489a
5 changed files with 2097 additions and 0 deletions
588
gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts
Normal file
588
gitnexus/src/core/ingestion/cfg/visitors/go-harvest.ts
Normal 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 } : {}),
|
||||
};
|
||||
}
|
||||
}
|
||||
892
gitnexus/src/core/ingestion/cfg/visitors/go.ts
Normal file
892
gitnexus/src/core/ingestion/cfg/visitors/go.ts
Normal 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 };
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
189
gitnexus/test/integration/cfg/fixtures/go-hazards.go
Normal file
189
gitnexus/test/integration/cfg/fixtures/go-hazards.go
Normal 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() {}
|
||||
426
gitnexus/test/unit/cfg/go-visitor.test.ts
Normal file
426
gitnexus/test/unit/cfg/go-visitor.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue