From 018635074a842c78269664063b5fb5d5d0411a67 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 10:59:00 +0000 Subject: [PATCH] feat(cfg): C and C++ CFG visitor + def/use harvest (#2195 U2) Add createCCfgVisitor/createCppCfgVisitor over a shared CCfgWalk core. Grammar introspection confirmed tree-sitter-c and tree-sitter-cpp share every control-flow node type/field, so CppCfgWalk extends CCfgWalk with only the C++-only nodes (try/catch/throw/for_range_loop/lambda) via a visitExtra hook -- no language conditionals (AGENTS no-language-naming). Wire both into c-cpp.ts providers. Harvest (c-cpp-harvest.ts): two-phase binding table + per-statement defs/uses/mayDefs (no sites[] yet -- U6). Edge kinds match the TS contract; functionStartColumn populated; non-terminating loops (for(;;), while(1)) emit the structural exit-escape edge so EXIT stays reverse-reachable and CDG is not silently skipped -- verified against the production post-dominator + control-dependence solvers (for(;;) -> 3 CDG edges). buildFunctionCfg returns undefined rather than throwing. 23 real-parser regression tests; grammar-literal gate green (literals validated against both grammars). Documented gaps: C++ RAII destructors, setjmp/longjmp, computed goto (route to EXIT + warn). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../ingestion/cfg/visitors/c-cpp-harvest.ts | 516 ++++++++++++ .../src/core/ingestion/cfg/visitors/c-cpp.ts | 773 ++++++++++++++++++ .../src/core/ingestion/languages/c-cpp.ts | 3 + .../test/integration/cfg/fixtures/c-hazards.c | 87 ++ .../integration/cfg/fixtures/cpp-hazards.cpp | 61 ++ gitnexus/test/unit/cfg/c-cpp-visitor.test.ts | 305 +++++++ 6 files changed, 1745 insertions(+) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/c-hazards.c create mode 100644 gitnexus/test/integration/cfg/fixtures/cpp-hazards.cpp create mode 100644 gitnexus/test/unit/cfg/c-cpp-visitor.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts new file mode 100644 index 000000000..3dfce8f24 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp-harvest.ts @@ -0,0 +1,516 @@ +/** + * C / C++ def/use harvester (#2195 U2, plan KTD2) — the C-family analogue of + * {@link import('./typescript-harvest.js').TsHarvester}. + * + * Runs in the parse worker next to the C/C++ 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. One class serves both languages: the control-flow node + * set is identical (grammar-introspection probe confirmed — see U2 report), + * and the harvest's def/use node taxonomy (`declaration`/`init_declarator`/ + * `assignment_expression`/`update_expression`/`parameter_declaration`) is shared + * too; C++-only `lambda_expression` is handled exactly like a nested function + * (opaque), so no language naming or branching is needed. + * + * TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS harvester): the + * CFG walk is NOT source-order (`visitFor` builds the init block after the body, + * `visitDoWhile` the condition before the body), so resolving names against a + * scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans + * the whole function subtree once into a completed lexical scope tree; phase 2 + * resolves defs/uses against that finished tree from any walk order. + * + * v1 def-semantics scope: + * - `declaration` → `init_declarator` (an initialized local is a def; a bare + * `int x;` with no initializer writes nothing at runtime — not a def, like + * the TS bare-`var` rule). + * - `assignment_expression` (plain + compound `+=` etc.), `update_expression` + * (`x++`/`--x`) — define and (for compound/update) also use the lvalue. + * - parameters (`parameter_declaration` declarator chain). + * EXCLUDED, deliberately (TypeScript-CFA precedent): member / pointer / array + * writes (`obj.f = …`, `*p = …`, `a[i] = …`) are NOT scalar defs — their + * identifiers are uses only. Both directions of nested-function (C++ lambda) + * capture are invisible (the lambda body is an opaque block in the enclosing + * CFG, exactly as TS treats arrow/function bodies). + * + * MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right + * operand of `&&`/`||` (`if (a && (x = f()))`), a ternary arm, or a switch + * case-test — is a may-def (gen without kill), so the not-taken path's prior + * def is not falsely killed. + * + * Identifiers with no in-function declaration (globals, macros, params of an + * enclosing scope) resolve to a SYNTHETIC module-level binding (`name@module`), + * applied identically by def and use harvesting. + * + * RAII NOTE: C++ destructors that run at scope exit are NOT represented in the + * tree-sitter AST (they are implicit), so this harvest cannot and does not model + * destructor side effects — documented gap, see the visitor doc-comment. + * + * 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(['lambda_expression', 'function_definition']); + +/** + * Nodes that open a lexical scope for block-local declarations. A `compound_ + * statement` is one scope; the for-loops open a scope for their loop variable. + */ +const SCOPE_TYPES = new Set([ + 'compound_statement', + 'for_statement', + 'for_range_loop', + 'catch_clause', +]); + +/** Type-position subtrees — identifiers inside them are not value uses. */ +const TYPE_CONTEXT_TYPES = new Set([ + 'type_descriptor', + 'template_argument_list', + 'template_type', + 'sized_type_specifier', + 'primitive_type', +]); + +interface Scope { + readonly parent: Scope | null; + /** name → binding index */ + readonly table: Map; +} + +export class CCppHarvester { + 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.declareParams(fnNode); + const body = this.bodyOf(fnNode); + if (body) this.prescan(body, this.openScope(body)); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + table(): readonly BindingEntry[] { + return this.bindings; + } + + /** The function/lambda body block (`compound_statement`). */ + private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined { + const body = fnNode.childForFieldName('body'); + if (body) return body; + return fnNode.namedChildren.find((c) => c.type === 'compound_statement'); + } + + // ── 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 || scope.table.has(name)) return; + scope.table.set(name, this.bindings.length); + this.bindings.push({ + name, + declLine: nameNode.startPosition.row + 1, + declColumn: nameNode.startPosition.column, + kind, + }); + } + + /** The bare identifier a declarator chain ultimately names (or undefined). */ + private declaratorName(node: SyntaxNode | null): SyntaxNode | undefined { + let cur: SyntaxNode | null = node; + let hops = 12; + while (cur && hops-- > 0) { + if (cur.type === 'identifier' || cur.type === 'field_identifier') return cur; + // Unwrap pointer/reference/array/init/parenthesized declarator layers. + const next = + cur.childForFieldName('declarator') ?? + cur.namedChildren.find( + (c) => + c.type === 'identifier' || + c.type === 'field_identifier' || + c.type === 'pointer_declarator' || + c.type === 'reference_declarator' || + c.type === 'array_declarator' || + c.type === 'parenthesized_declarator' || + c.type === 'init_declarator', + ); + if (!next || next.id === cur.id) break; + cur = next; + } + return undefined; + } + + private declareParams(fnNode: SyntaxNode): void { + // function_definition: declarator → function_declarator → parameter_list. + // A C++ lambda routes through abstract_function_declarator. + const declarator = fnNode.childForFieldName('declarator'); + const fnDeclarator = this.findFunctionDeclarator(declarator); + const params = fnDeclarator?.childForFieldName('parameters'); + if (!params) return; + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'parameter_declaration') continue; + const name = this.declaratorName(p.childForFieldName('declarator') ?? null); + if (name) this.declare(name, 'param', this.root); + } + } + + private findFunctionDeclarator(node: SyntaxNode | null): SyntaxNode | undefined { + let cur: SyntaxNode | null = node; + let hops = 10; + while (cur && hops-- > 0) { + if (cur.type === 'function_declarator' || cur.type === 'abstract_function_declarator') { + return cur; + } + cur = cur.childForFieldName('declarator') ?? null; + } + return undefined; + } + + 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/lambda body is opaque — do not descend. + return; + } + + let childScope = scope; + if (SCOPE_TYPES.has(t)) childScope = this.openScope(node); + + switch (t) { + case 'declaration': + this.declareDeclarators(node, childScope); + break; + case 'for_range_loop': { + // `for (int x : xs)` — the declarator binds in the loop scope. + const decl = node.childForFieldName('declarator'); + const name = this.declaratorName(decl ?? null); + if (name) this.declare(name, 'var', childScope); + break; + } + case 'catch_clause': { + const params = node.childForFieldName('parameters'); + if (params) { + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'parameter_declaration') continue; + const name = this.declaratorName(p.childForFieldName('declarator') ?? null); + if (name) this.declare(name, 'catch', childScope); + } + } + break; + } + default: + break; + } + + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.prescan(c, childScope); + } + } + + private declareDeclarators(declNode: SyntaxNode, scope: Scope): void { + for (let i = 0; i < declNode.namedChildCount; i++) { + const d = declNode.namedChild(i); + if (!d) continue; + if (d.type === 'init_declarator') { + const name = this.declaratorName(d.childForFieldName('declarator') ?? null); + if (name) this.declare(name, 'var', scope); + } else if ( + d.type === 'identifier' || + d.type === 'pointer_declarator' || + d.type === 'array_declarator' || + d.type === 'reference_declarator' + ) { + // Uninitialized local (`int x;`) — declare the BINDING (so a later + // assignment resolves to a real, non-synthetic binding) but the + // declaration itself produces no def (handled in phase 2). + const name = this.declaratorName(d); + if (name) this.declare(name, 'var', scope); + } + } + } + + // ── 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 (decl : right)` range head: decl binds, right is used. */ + forRangeHeadFacts(stmt: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(stmt.startPosition.row + 1); + const decl = stmt.childForFieldName('declarator'); + const right = stmt.childForFieldName('right'); + const name = this.declaratorName(decl ?? null); + if (name) this.def(name, acc); + if (right) this.walkValue(right, acc); + return acc.finish(); + } + + /** ENTRY-block facts for the function's parameters (defs only). */ + paramFacts(): StatementFacts | undefined { + const declarator = this.fnNode.childForFieldName('declarator'); + const fnDeclarator = this.findFunctionDeclarator(declarator); + const params = fnDeclarator?.childForFieldName('parameters'); + if (!params) return undefined; + const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'parameter_declaration') continue; + const name = this.declaratorName(p.childForFieldName('declarator') ?? null); + if (name) this.def(name, acc); + } + return acc.defCount() ? acc.finish() : undefined; + } + + /** Def fact for a `catch (T& e)` parameter — prepend to the handler entry block. */ + catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined { + const params = catchClause.childForFieldName('parameters'); + if (!params) return undefined; + const acc = new FactAccumulator(catchClause.startPosition.row + 1); + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p?.type !== 'parameter_declaration') continue; + const name = this.declaratorName(p.childForFieldName('declarator') ?? null); + if (name) this.def(name, 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 (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode)); + else acc.addDef(this.resolve(nameNode)); + } + + private use(nameNode: SyntaxNode, acc: FactAccumulator): void { + 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; + } + + /** Value-position walk: collect uses; route def positions to the lvalue handler. */ + private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + const t = node.type; + if (TYPE_CONTEXT_TYPES.has(t)) return; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) { + // Opaque nested function / lambda — captured reads/writes are invisible. + return; + } + + switch (t) { + case 'identifier': + case 'field_identifier': + this.use(node, acc); + return; + case 'declaration': + for (let i = 0; i < node.namedChildCount; i++) { + const d = node.namedChild(i); + if (d?.type !== 'init_declarator') continue; + const declarator = d.childForFieldName('declarator'); + const value = d.childForFieldName('value'); + const name = declarator ? this.declaratorName(declarator) : undefined; + // Only an INITIALIZED declarator writes (`int x = e;`). A bare + // `int x;` is not a def (it writes nothing at runtime), matching the + // TS bare-`var` rule. Pointer/array/member declarators are not scalar + // defs either — their inner identifiers stay uses. + if (name && value && declarator?.type === 'identifier') this.def(name, acc); + else if (declarator && declarator.type !== 'identifier') this.walkValue(declarator, acc); + if (value) this.walkValue(value, acc); + } + return; + case 'assignment_expression': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + const op = node.childForFieldName('operator')?.text ?? '='; + if (left) { + const lv = this.unwrapLvalue(left); + if (lv.type === 'identifier') { + this.def(lv, acc); + if (op !== '=') this.use(lv, acc); // compound assign reads too + } else { + this.walkValue(lv, acc); // member/pointer/subscript target — uses only + } + } + if (right) this.walkValue(right, acc); + return; + } + case 'update_expression': { + const rawArg = node.childForFieldName('argument'); + const arg = rawArg ? this.unwrapLvalue(rawArg) : null; + if (arg?.type === 'identifier') { + this.def(arg, acc); + this.use(arg, acc); + } else if (arg) { + this.walkValue(arg, 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 'conditional_expression': { + const cond = node.childForFieldName('condition'); + const cons = node.childForFieldName('consequence'); + const alt = node.childForFieldName('alternative'); + if (cond) this.walkValue(cond, acc); + if (cons) this.conditional(() => this.walkValue(cons, acc)); + if (alt) this.conditional(() => this.walkValue(alt, acc)); + return; + } + case 'field_expression': { + // `a.b` / `a->b` — value read of the chain root only; the field name is + // not a scalar binding. Mirrors the TS member-read use semantics. + const arg = node.childForFieldName('argument'); + if (arg) this.walkValue(arg, acc); + return; + } + default: + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c && !TYPE_CONTEXT_TYPES.has(c.type)) 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/c-cpp.ts b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts new file mode 100644 index 000000000..839b6daa1 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/c-cpp.ts @@ -0,0 +1,773 @@ +/** + * C / C++ CfgVisitor (#2195 U2, plan KTD1). + * + * Walks a C or C++ function's tree-sitter AST and drives the language-agnostic + * {@link CfgBuilder} to produce a serializable {@link FunctionCfg}, plus a + * def/use harvest ({@link CCppHarvester}) for the reaching-defs / CDG solvers. + * + * SHARED CORE, TWO FACTORIES. A grammar-introspection probe (mandatory pre-step, + * KTD1) confirmed every control-flow node type and field this visitor uses is + * IDENTICAL between tree-sitter-c and tree-sitter-cpp — `if_statement`, + * `for_statement`, `while_statement`, `do_statement`, `switch_statement` / + * `case_statement`, `return`/`break`/`continue`/`goto_statement` / + * `labeled_statement`, `compound_statement`, and the `condition`/`consequence`/ + * `alternative`/`initializer`/`update`/`body`/`label`/`value` fields. So the C + * walk ({@link CCfgWalk}) is grammar-shared, and C++ EXTENDS it ({@link + * CppCfgWalk}) with exception flow (`try_statement` / `catch_clause` / + * `throw_statement`) and `for_range_loop` — node types that simply never occur + * in a C parse, so there is no `if (lang === …)` branching (AGENTS.md + * no-language-naming rule). The two factories differ only in which walk class + * and function-node set they install. + * + * Edge-kind contract (matches the TS visitor — RD/CDG consume these): + * - if/else → `cond-true` / `cond-false` + * - loops (for / while / do-while / for-range) → `cond-true` / `loop-back` / + * `cond-false` + * - switch → `switch-case` / `fallthrough` (C-style: a case body that does not + * `break` falls into the next case) + * - try/catch (C++) → `throw` (every protected-region block → the handler) + * - return / throw / break / continue → the matching terminator kind + * - straight-line → `seq` + * + * Classic hazards, handled explicitly: + * - loops allocate a dedicated loop-exit block so `break` has a target before + * the loop's successor is known; `continue` targets the header/increment. + * - `for (;;) {}` / `while (1) {}` still emit the structural `header → loopExit` + * `cond-false` escape edge so EXIT stays reverse-reachable from every block — + * the post-dominator / CDG pass is unsound otherwise (it silently emits zero + * CDG for the function). + * - C `goto`/`labeled_statement`: labels resolve within the function (forward + * AND backward); an unresolved `goto` (label not in this function) routes to + * EXIT and logs via the builder warn path, preserving single-exit. + * - C++ `try`/`catch`: conservative exceptional flow — EVERY block in the + * protected region edges to the handler (an exception may fire mid-block), + * matching the TS `visitTry` over-approximation. A `throw` with no enclosing + * try routes to EXIT. + * + * Known limitations: + * - C++ RAII: a destructor runs implicitly at scope exit, but tree-sitter does + * NOT represent that call in the AST. This visitor therefore does NOT model + * destructor-at-scope-exit control/data flow — it is a documented gap, not + * faked. (C++ has no `finally`; `try`/`catch` is the only modeled exception + * construct, so the TS finalizer-frame machinery is unused here.) + * - A `goto` whose label is undefined in the function keeps the conservative + * route-to-EXIT fallback (single-exit preserved; the continuation path is + * approximate). `setjmp`/`longjmp` non-local control is not modeled. + * - Computed `goto` (`goto *ptr;`, a GNU extension) has no static label target + * and routes to EXIT like an unresolved label. + * - Def/use harvest scope: see `c-cpp-harvest.ts` — member/pointer/array writes + * are not scalar defs; C++ lambda 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 type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg } from '../types.js'; +import { CCppHarvester } from './c-cpp-harvest.js'; + +/** C function node — only `function_definition` owns a CFG-bearing body. */ +const C_FUNCTION_TYPES = new Set(['function_definition']); +/** C++ adds the lambda as a CFG-bearing function. */ +const CPP_FUNCTION_TYPES = new Set(['function_definition', 'lambda_expression']); + +/** Statement node types that break a basic block (everything else coalesces). */ +const C_CONTROL_FLOW_TYPES = new Set([ + 'if_statement', + 'while_statement', + 'do_statement', + 'for_statement', + 'switch_statement', + 'return_statement', + 'break_statement', + 'continue_statement', + 'goto_statement', + 'labeled_statement', + 'compound_statement', +]); + +/** C++ control-flow node types (C set ∪ exceptions ∪ range-for). */ +const CPP_CONTROL_FLOW_TYPES = new Set([ + ...C_CONTROL_FLOW_TYPES, + 'for_range_loop', + 'try_statement', + 'throw_statement', +]); + +const LOOP_OR_SWITCH_TYPES = new Set([ + 'while_statement', + 'do_statement', + 'for_statement', + 'for_range_loop', + 'switch_statement', +]); + +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; + +/** A `break`/`continue` jump-target frame (loop or switch). */ +interface JumpFrame { + readonly kind: 'loop' | 'switch'; + /** Where a `break` jumps to (loop exit / switch exit). */ + readonly breakTo: number; + /** Where a `continue` jumps to (loop header / increment). -1 for a switch. */ + readonly continueTo: number; +} + +/** + * Per-function C walk state. One instance per function so the jump-target stack, + * exception-handler stack, and label tables are scoped to that function. + * + * Designed to be EXTENDED by the C++ walk: the dispatch table is open via the + * protected {@link visitStmt} override hook, so C++ adds its node types without + * any language conditional in the C core. + */ +class CCfgWalk { + protected readonly jumps: JumpFrame[] = []; + /** Stack of exception-handler entry blocks (catch) a `throw` jumps to. */ + protected readonly handlers: number[] = []; + /** label name → its `labeled_statement` body's entry block (resolved on demand). */ + protected readonly labelBlocks = new Map(); + /** Pending gotos to a label not yet seen: label → list of source blocks. */ + protected readonly pendingGotos = new Map(); + /** Set of node types that break a block, used by {@link visitSeq}. */ + protected readonly controlFlowTypes: ReadonlySet; + + constructor( + protected readonly builder: CfgBuilder, + protected readonly harvest: CCppHarvester, + controlFlowTypes: ReadonlySet, + ) { + this.controlFlowTypes = controlFlowTypes; + } + + /** Statements of a block node, ignoring comments. */ + protected statementsOf(block: SyntaxNode): SyntaxNode[] { + return block.namedChildren.filter((c) => c.type !== 'comment'); + } + + /** The `body` block of a node (field, or the first compound_statement child). */ + protected bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined { + return ( + node.childForFieldName('body') ?? + node.namedChildren.find((c) => c.type === 'compound_statement') + ); + } + + /** Visit a body that may be a `compound_statement` or a single statement. */ + protected visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (node.type === 'compound_statement') 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 (this.controlFlowTypes.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 'while_statement': + return this.visitWhile(stmt); + case 'do_statement': + return this.visitDoWhile(stmt); + case 'for_statement': + return this.visitFor(stmt); + case 'switch_statement': + return this.visitSwitch(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 'compound_statement': + return this.visitSeq(this.statementsOf(stmt)); + default: + return this.visitExtra(stmt) ?? this.visitSimple(stmt); + } + } + + /** + * Extension hook for node types the C core does not handle (C++ try/catch/ + * throw/for-range). Returns `undefined` to fall through to {@link visitSimple}. + * The C core has none, so this is a no-op here. + */ + protected visitExtra(_stmt: SyntaxNode): SeqResult | undefined { + return undefined; + } + + protected 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] }; + } + + protected visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.builder.exitIndex, 'return'); + return { entry: idx, exits: [] }; + } + + protected visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const target = this.nearestBreakTarget(); + this.builder.edge(idx, target ?? this.builder.exitIndex, 'break'); + return { entry: idx, exits: [] }; + } + + protected visitContinue(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const target = this.nearestContinueTarget(); + this.builder.edge(idx, target ?? this.builder.exitIndex, 'continue'); + return { entry: idx, exits: [] }; + } + + /** Nearest enclosing loop/switch `break` target, or undefined (→ EXIT). */ + private nearestBreakTarget(): number | undefined { + return this.jumps.length ? this.jumps[this.jumps.length - 1].breakTo : undefined; + } + + /** Nearest enclosing loop `continue` target (switches don't catch continue). */ + private nearestContinueTarget(): number | undefined { + for (let i = this.jumps.length - 1; i >= 0; i--) { + if (this.jumps[i].kind === 'loop') return this.jumps[i].continueTo; + } + return undefined; + } + + /** `goto label;` — route to the label block if known, else defer / EXIT. */ + protected visitGoto(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const label = this.labelOf(stmt); + if (label === undefined) { + // Computed goto (`goto *p;`) or malformed — route to EXIT (single-exit). + this.builder.edge(idx, this.builder.exitIndex, 'seq'); + 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 { + // Forward goto — wire once the label is created (or to EXIT at finish()). + const list = this.pendingGotos.get(label); + if (list) list.push(idx); + else this.pendingGotos.set(label, [idx]); + } + return { entry: idx, exits: [] }; + } + + protected visitLabeled(stmt: SyntaxNode): SeqResult { + const label = this.labelOf(stmt); + // The labeled statement's body is the trailing named child (no `body` field + // on labeled_statement in C/C++). + const body = + stmt.namedChildren.find((c) => c.type !== 'statement_identifier' && c.type !== 'comment') ?? + null; + const res = this.visitBody(body); + if (label !== undefined) { + const entry = res?.entry ?? this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + this.labelBlocks.set(label, entry); + // Resolve any forward gotos that were waiting on this label. + const pending = this.pendingGotos.get(label); + if (pending) { + for (const from of pending) this.builder.edge(from, entry, 'seq'); + this.pendingGotos.delete(label); + } + if (!res) return { entry, exits: [entry] }; + } + return res; + } + + protected visitIf(stmt: SyntaxNode): TraversalResult { + const cond = stmt.childForFieldName('condition') ?? stmt; + const condBlock = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const exits: number[] = []; + + const thenRes = this.visitBody(stmt.childForFieldName('consequence')); + if (thenRes) { + this.builder.edge(condBlock, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(condBlock); // empty then — true path falls through + } + + const elseNode = this.elseBodyOf(stmt); + if (elseNode) { + const elseRes = this.visitBody(elseNode); + if (elseRes) { + this.builder.edge(condBlock, elseRes.entry, 'cond-false'); + exits.push(...elseRes.exits); + } else { + exits.push(condBlock); + } + } else { + exits.push(condBlock); // no else — false path falls through to the join + } + + return { entry: condBlock, exits: [...new Set(exits)] }; + } + + /** The else body node (unwraps an `else_clause` wrapper if present). */ + private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined { + const alt = ifStmt.childForFieldName('alternative'); + if (!alt) return undefined; + if (alt.type === 'else_clause') { + return alt.childForFieldName('body') ?? alt.namedChildren[0]; + } + return alt; // an `else if` is the nested if_statement directly + } + + protected visitWhile(stmt: SyntaxNode): TraversalResult { + const cond = stmt.childForFieldName('condition') ?? stmt; + const header = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: header }); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.jumps.pop(); + + if (body) { + this.builder.edge(header, body.entry, 'cond-true'); + this.builder.connect(body.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); // empty body re-tests + } + // Always emit the structural exit edge — even `while (1)` keeps EXIT + // reverse-reachable for the post-dominator / CDG pass. + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + protected visitDoWhile(stmt: SyntaxNode): TraversalResult { + const cond = stmt.childForFieldName('condition') ?? stmt; + const condBlock = this.builder.newBlock( + startLineOf(cond), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: condBlock }); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.jumps.pop(); + + const backTarget = body ? body.entry : condBlock; + if (body) this.builder.connect(body.exits, condBlock, 'seq'); + this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again + this.builder.edge(condBlock, loopExit, 'cond-false'); + return { entry: backTarget, exits: [loopExit] }; + } + + protected visitFor(stmt: SyntaxNode): TraversalResult { + const init = stmt.childForFieldName('initializer'); + const cond = stmt.childForFieldName('condition'); + const incr = stmt.childForFieldName('update'); + + const header = this.builder.newBlock( + 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.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: incrBlock }); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.jumps.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] }; + } + + protected visitSwitch(stmt: SyntaxNode): TraversalResult { + const value = stmt.childForFieldName('condition') ?? stmt; + const dispatch = this.builder.newBlock( + startLineOf(stmt), + endLineOf(value), + value.text, + 'normal', + this.harvest.facts(value), + ); + const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.jumps.push({ kind: 'switch', breakTo: switchExit, continueTo: -1 }); + const body = stmt.childForFieldName('body'); + // C/C++ uses ONE `case_statement` node for both `case X:` and `default:`; + // a default has no `value` field. + const cases = body ? body.namedChildren.filter((c) => c.type === 'case_statement') : []; + + // `case X:` test expressions live in no block — harvest their uses onto the + // dispatch block as may-defs/uses (sound over-approx of in-order evaluation). + for (const c of cases) { + const caseValue = c.childForFieldName('value'); + if (caseValue) this.builder.attachFacts(dispatch, this.harvest.factsConditional(caseValue)); + } + + const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c))); + const hasDefault = cases.some((c) => !c.childForFieldName('value')); + + 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 + + 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; + this.builder.connect(res.exits, fallTarget, 'fallthrough'); + } + + this.jumps.pop(); + return { entry: dispatch, exits: [switchExit] }; + } + + /** A case's body statements (everything but the `value` test and comments). */ + private caseStatements(caseNode: SyntaxNode): SyntaxNode[] { + const value = caseNode.childForFieldName('value'); + return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment'); + } + + /** Nearest enclosing exception handler, or the function EXIT. */ + protected currentHandler(): number { + return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex; + } + + private labelOf(stmt: SyntaxNode): string | undefined { + const id = + stmt.childForFieldName('label') ?? + stmt.namedChildren.find((c) => c.type === 'statement_identifier'); + return id?.text; + } + + /** + * Drain any forward gotos whose label never appeared in the function (a label + * defined in a header macro, or malformed source) — route them to EXIT so the + * graph stays single-exit. Logs via console.warn (the builder's warn path) + * so a dropped jump is never silent (R4). Called once after the body walk. + */ + finishGotos(): void { + for (const [label, froms] of this.pendingGotos) { + // eslint-disable-next-line no-console + console.warn(`[cfg] unresolved goto label "${label}" routed to EXIT (${froms.length} site(s))`); + for (const from of froms) this.builder.edge(from, this.builder.exitIndex, 'seq'); + } + this.pendingGotos.clear(); + } +} + +/** + * C++ walk — extends the C core with exception flow and the range-for loop. + * These node types never appear in a C parse, so no language conditional is + * needed; the C core dispatches them through {@link visitExtra}. + */ +class CppCfgWalk extends CCfgWalk { + protected override visitExtra(stmt: SyntaxNode): SeqResult | undefined { + switch (stmt.type) { + case 'for_range_loop': + return this.visitForRange(stmt); + case 'try_statement': + return this.visitTry(stmt); + case 'throw_statement': + return this.visitThrow(stmt); + default: + return undefined; + } + } + + private visitThrow(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + this.builder.edge(idx, this.currentHandler(), 'throw'); + return { entry: idx, exits: [] }; + } + + private visitForRange(stmt: SyntaxNode): TraversalResult { + // Header text is synthesized; facts come from the declarator (def) + the + // iterated expression (use) directly. + const header = this.builder.newBlock( + startLineOf(stmt), + startLineOf(stmt), + this.forRangeHeaderText(stmt), + 'normal', + this.harvest.forRangeHeadFacts(stmt), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.jumps.push({ kind: 'loop', breakTo: loopExit, continueTo: header }); + const body = this.visitBody(this.bodyBlockOf(stmt)); + this.jumps.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 forRangeHeaderText(stmt: SyntaxNode): string { + const decl = stmt.childForFieldName('declarator')?.text ?? ''; + const right = stmt.childForFieldName('right')?.text ?? ''; + return decl || right ? `for(${decl} : ${right})` : 'for(… : …)'; + } + + /** + * try / catch (C++ has no `finally`). Conservative exceptional flow: every + * block created while walking the protected body edges to the (first) catch + * handler — an exception may fire mid-block, and a branched body must still + * reach the handler from any interior block (matching the TS visitTry + * over-approximation). Multiple `catch` clauses chain: the body's throw routes + * to the first handler; each handler's normal completion joins the post-try + * continuation. + */ + private visitTry(stmt: SyntaxNode): SeqResult { + const bodyNode = stmt.childForFieldName('body'); + const catchClauses: SyntaxNode[] = []; + for (let i = 0; i < stmt.namedChildCount; i++) { + const c = stmt.namedChild(i); + if (c?.type === 'catch_clause') catchClauses.push(c); + } + + // Build each catch handler. The handler entry is the catch-param binding + // block (a facts-only block) in front of the body, so the exception's + // binding happens exactly once on handler entry. + const handlerEntries: number[] = []; + const handlerExits: number[] = []; + for (const clause of catchClauses) { + const clauseBody = this.bodyBlockOf(clause); + let res: SeqResult = clauseBody ? this.visitSeq(this.statementsOf(clauseBody)) : null; + if (res === null) { + // Empty catch body still CATCHES — synthesize one block so the + // exception lands somewhere and the post-try code stays reachable. + const idx = this.builder.newBlock(startLineOf(clause), endLineOf(clause), ''); + res = { entry: idx, exits: [idx] }; + } + const paramFacts = this.harvest.catchParamFacts(clause); + if (paramFacts) { + const paramBlock = this.builder.newBlock( + startLineOf(clause), + startLineOf(clause), + '', + 'normal', + paramFacts, + ); + this.builder.edge(paramBlock, res.entry, 'seq'); + res = { entry: paramBlock, exits: res.exits }; + } + handlerEntries.push(res.entry); + handlerExits.push(...res.exits); + } + + // The protected body's handler is the FIRST catch (if any), else the outer. + const tryHandler = handlerEntries[0] ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null; + this.handlers.pop(); + + // Conservative exceptional edges: every protected-region block → the handler. + if (catchClauses.length > 0) { + for (let b = protectedStart; b < this.builder.blockCount; b++) { + this.builder.edge(b, tryHandler, 'throw'); + } + } + + const exits: number[] = []; + if (bodyRes) exits.push(...bodyRes.exits); + exits.push(...handlerExits); + // No catch clause at all — an exception re-propagates to the outer handler. + if (catchClauses.length === 0 && bodyRes) { + // (A bare `try {}` with no catch is ill-formed C++, but stay robust.) + this.builder.connect(bodyRes.exits, this.currentHandler(), 'throw'); + } + + const entry = bodyRes?.entry ?? handlerEntries[0]; + if (entry === undefined) return null; + return { entry, exits: [...new Set(exits)] }; + } +} + +/** Build the CFG for one C/C++ function node, or `undefined` if not modelable. */ +function buildFunctionCfg( + fnNode: SyntaxNode, + filePath: string, + functionTypes: ReadonlySet, + controlFlowTypes: ReadonlySet, + WalkClass: typeof CCfgWalk, +): FunctionCfg | undefined { + try { + if (!functionTypes.has(fnNode.type)) return undefined; + const startLine = startLineOf(fnNode); + const endLine = endLineOf(fnNode); + const startColumn = fnNode.startPosition.column; + + // The body is a compound_statement (field `body`, or first such child). + const body = + fnNode.childForFieldName('body') ?? + fnNode.namedChildren.find((c) => c.type === 'compound_statement'); + if (!body || body.type !== 'compound_statement') return undefined; // declaration / no body + + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new CCppHarvester(fnNode); + + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + + const walk = new WalkClass(builder, harvest, controlFlowTypes); + const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment')); + walk.finishGotos(); + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(harvest.table()); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + 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] C/C++ buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`); + return undefined; + } +} + +/** The C CFG visitor. */ +export function createCCfgVisitor(): CfgVisitor { + return { + isFunction: (node) => C_FUNCTION_TYPES.has(node.type), + buildFunctionCfg: (fnNode, filePath) => + buildFunctionCfg(fnNode, filePath, C_FUNCTION_TYPES, C_CONTROL_FLOW_TYPES, CCfgWalk), + }; +} + +/** The C++ CFG visitor (C core + exceptions + range-for + lambdas). */ +export function createCppCfgVisitor(): CfgVisitor { + return { + isFunction: (node) => CPP_FUNCTION_TYPES.has(node.type), + buildFunctionCfg: (fnNode, filePath) => + buildFunctionCfg(fnNode, filePath, CPP_FUNCTION_TYPES, CPP_CONTROL_FLOW_TYPES, CppCfgWalk), + }; +} + +export { C_FUNCTION_TYPES, CPP_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/c-cpp.ts b/gitnexus/src/core/ingestion/languages/c-cpp.ts index 3d427fed8..172bea0cd 100644 --- a/gitnexus/src/core/ingestion/languages/c-cpp.ts +++ b/gitnexus/src/core/ingestion/languages/c-cpp.ts @@ -70,6 +70,7 @@ import { type CppConstraintPayload, } from './cpp/constraint-extractor.js'; import { assertCloneable } from '../workers/clone-safety.js'; +import { createCCfgVisitor, createCppCfgVisitor } from '../cfg/visitors/c-cpp.js'; const C_BUILT_INS: ReadonlySet = new Set([ 'printf', @@ -401,6 +402,7 @@ export const cProvider = defineLanguage({ // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── emitScopeCaptures: emitCScopeCaptures, + cfgVisitor: createCCfgVisitor(), // Worker-side: snapshot the module-level `static`-linkage marks // `emitCScopeCaptures` just populated for this file (`markStaticName` → // `staticNames`) into plain data on `ParsedFile.captureSideChannel`, so the @@ -484,6 +486,7 @@ export const cppProvider = defineLanguage({ // ── RFC #909 Ring 3: scope-based resolution hooks (RFC §5) ────────── emitScopeCaptures: emitCppScopeCaptures, + cfgVisitor: createCppCfgVisitor(), // Worker-side: snapshot the module-level capture marks `emitCppScopeCaptures` // just populated for this file into plain data on `ParsedFile.captureSideChannel`, // so the main thread can restore them via `applyCaptureSideChannel` WITHOUT a diff --git a/gitnexus/test/integration/cfg/fixtures/c-hazards.c b/gitnexus/test/integration/cfg/fixtures/c-hazards.c new file mode 100644 index 000000000..ec089adaf --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/c-hazards.c @@ -0,0 +1,87 @@ +/* C CFG hazard fixture (#2195 U2). Exercises every modeled C control-flow + * construct so the pipeline emits BasicBlock + CFG + REACHING_DEF + CDG. */ + +int straight_line(int a, int b) { + int x = a + b; + int y = x * 2; + return y; +} + +int if_else(int x) { + int r; + if (x > 0) { + r = 1; + } else { + r = -1; + } + return r; +} + +int while_loop(int n) { + int i = 0; + int sum = 0; + while (i < n) { + sum = sum + i; + i++; + } + return sum; +} + +int do_while_loop(int n) { + int i = 0; + int sum = 0; + do { + sum = sum + i; + i++; + } while (i < n); + return sum; +} + +int for_loop(int n) { + int sum = 0; + for (int i = 0; i < n; i++) { + sum = sum + i; + } + return sum; +} + +const char *switch_fallthrough(int code) { + const char *msg; + switch (code) { + case 1: + msg = "one"; + case 2: + msg = "two-or-more"; + break; + default: + msg = "other"; + } + return msg; +} + +int goto_jump(int n) { + int i = 0; + int sum = 0; +loop: + if (i >= n) goto end; + sum = sum + i; + i++; + goto loop; +end: + return sum; +} + +/* Non-terminating loop: EXIT must stay reverse-reachable for the CDG pass. */ +void server_forever(void) { + for (;;) { + handle_request(); + } +} + +int may_def(int a) { + int x = 0; + if (a && (x = compute())) { + use(x); + } + return x; +} diff --git a/gitnexus/test/integration/cfg/fixtures/cpp-hazards.cpp b/gitnexus/test/integration/cfg/fixtures/cpp-hazards.cpp new file mode 100644 index 000000000..7deab3764 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/cpp-hazards.cpp @@ -0,0 +1,61 @@ +// C++ CFG hazard fixture (#2195 U2). Exercises the C core plus the C++-only +// constructs: try/catch, throw, range-for, and lambdas. + +#include +#include + +int if_else(int x) { + int r; + if (x > 0) { + r = 1; + } else { + r = -1; + } + return r; +} + +int try_catch(int x) { + int result = 0; + try { + if (x < 0) { + throw std::runtime_error("negative"); + } + result = compute(x); + } catch (const std::exception &e) { + result = -1; + handle(e); + } + return result; +} + +void throw_no_try(int x) { + if (x < 0) { + throw std::runtime_error("bad"); + } + proceed(x); +} + +int range_for(const std::vector &xs) { + int sum = 0; + for (int x : xs) { + sum = sum + x; + } + return sum; +} + +int with_lambda(int n) { + auto doubler = [](int v) { + if (v > 0) { + return v * 2; + } + return 0; + }; + return doubler(n); +} + +// Non-terminating server loop — EXIT must stay reverse-reachable for the CDG pass. +void run_forever() { + while (true) { + poll(); + } +} diff --git a/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts new file mode 100644 index 000000000..1970f73aa --- /dev/null +++ b/gitnexus/test/unit/cfg/c-cpp-visitor.test.ts @@ -0,0 +1,305 @@ +import { describe, it, expect, vi } from 'vitest'; +import { createRequire } from 'node:module'; +import { requireVendoredGrammar } from '../../../src/core/tree-sitter/vendored-grammars.js'; +import { + createCCfgVisitor, + createCppCfgVisitor, +} from '../../../src/core/ingestion/cfg/visitors/c-cpp.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; + +// U2 — the C/C++ CfgVisitor, one hazard per test (KTD5: real-parser regression, +// NOT snapshot-pinning). Each fixture's distinctive statement text (step(), +// done(), handle(e), …) lets us locate the block for a region by text and assert +// the control-flow topology around it. + +const cGrammar = requireVendoredGrammar('tree-sitter-c') as Parameters[0]; +const cppGrammar = createRequire(import.meta.url)('tree-sitter-cpp') as Parameters< + typeof makeCfgHarness +>[0]; + +const c: CfgHarness = makeCfgHarness(cGrammar, createCCfgVisitor(), 'fixture.c'); +const cpp: CfgHarness = makeCfgHarness(cppGrammar, createCppCfgVisitor(), 'fixture.cpp'); + +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); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +describe('C CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT (seq)', () => { + const cfg = c.cfgOf(`void f() { a(); b(); c(); }`); + expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a();'); + expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: body, kind: 'seq' }); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty body: ENTRY → EXIT', () => { + const cfg = c.cfgOf(`void f() {}`); + expect(cfg.blocks).toHaveLength(2); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('malformed/unmodeled body returns undefined without throwing', () => { + // A forward-declaration prototype (`int f(int);`) has no compound_statement + // body — buildFunctionCfg must return undefined rather than throw. + const root = c.parse(`int f(int);`); + const fns = c.collectFunctions(root); + // No function_definition (only a declaration) → nothing to build. + expect(fns).toHaveLength(0); + // And a body-less function node yields undefined, not a throw. + const decl = c.parse(`void g() { x(); }`); + const fn = c.collectFunctions(decl)[0]; + expect(() => createCCfgVisitor().buildFunctionCfg(fn, 'f.c')).not.toThrow(); + }); +}); + +describe('C CfgVisitor — branching', () => { + it('if/else: cond-true to then, cond-false to else, both reach the join', () => { + const cfg = c.cfgOf(`void f(int x) { if (x) { a(); } else { b(); } c(); }`); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const join = block(cfg, 'c();'); + expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true); + }); + + it('plain if (no else): condition reaches both the body and the join', () => { + const cfg = c.cfgOf(`void f(int x) { if (x) { a(); } b(); }`); + const cond = block(cfg, 'x'); + expect(reaches(cfg, cond, block(cfg, 'a();'))).toBe(true); + expect(reaches(cfg, cond, block(cfg, 'b();'))).toBe(true); + }); +}); + +describe('C CfgVisitor — loops', () => { + it('while loop: header + back-edge + exit', () => { + const cfg = c.cfgOf(`void f(int x) { while (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('do-while runs the body BEFORE testing, then loops back from the bottom', () => { + const cfg = c.cfgOf(`void f(int x) { do { step(); } while (x > 0); done(); }`); + const body = block(cfg, 'step();'); + const cond = block(cfg, 'x > 0'); + expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first + // The back-edge / condition tests at the BOTTOM (cond reachable from body). + expect(reaches(cfg, body, cond)).toBe(true); + expect(cfg.edges).toContainEqual({ from: cond, to: body, kind: 'loop-back' }); + expect(reaches(cfg, cond, block(cfg, 'done();'))).toBe(true); + }); + + it('C-style for: init once, condition header, back-edge through increment', () => { + const cfg = c.cfgOf(`void f() { for (int i = 0; i < n; i++) { step(); } done(); }`); + const init = block(cfg, 'int 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(;;) {} keeps EXIT reverse-reachable (structural exit-escape edge)', () => { + const cfg = c.cfgOf(`void f() { for (;;) { work(); } }`); + // The header has a cond-false escape to the loop-exit even with no condition. + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); +}); + +describe('C CfgVisitor — switch (C-style fallthrough)', () => { + it('a case without break falls into the next case (switch-case + fallthrough)', () => { + const cfg = c.cfgOf(`void f(int x) { + switch (x) { + case 1: one(); + case 2: two(); break; + default: other(); + } + after(); + }`); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(edgeKinds(cfg).has('fallthrough')).toBe(true); + // case 1 (no break) FALLS THROUGH into case 2. + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(true); + // both cases reach the post-switch continuation. + expect(reaches(cfg, block(cfg, 'two();'), block(cfg, 'after();'))).toBe(true); + }); + + it('break-terminated case does not fall into the next case', () => { + const cfg = c.cfgOf(`void f(int x) { + switch (x) { case 1: one(); break; case 2: two(); break; } + after(); + }`); + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false); + expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'after();'))).toBe(true); + }); +}); + +describe('C CfgVisitor — goto / labels', () => { + it('backward goto wires to an already-seen label block', () => { + const cfg = c.cfgOf(`void f() { int i = 0; loop: work(); i++; if (i < 10) goto loop; done(); }`); + const gotoB = block(cfg, 'goto loop;'); + const label = block(cfg, 'work();'); + expect(reaches(cfg, gotoB, label)).toBe(true); + expect(cfg.edges.some((e) => e.from === gotoB && e.to === label)).toBe(true); + }); + + it('forward goto wires to a label that appears later', () => { + const cfg = c.cfgOf(`void f(int x) { 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); + // the goto skips work() on its path. + expect(reachable(cfg, block(cfg, 'work();'))).toBe(true); + }); + + it('goto to an UNDEFINED label routes to EXIT (single-exit preserved) and warns', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const cfg = c.cfgOf(`void f() { work(); goto missing; }`); + const gotoB = block(cfg, 'goto missing;'); + expect(reaches(cfg, gotoB, cfg.exitIndex)).toBe(true); + expect(warn).toHaveBeenCalled(); + } finally { + warn.mockRestore(); + } + }); +}); + +describe('C CfgVisitor — def/use harvest', () => { + it('int x = a + b; use(x); produces a def of x and a use in the consumer', () => { + const cfg = c.cfgOf(`void f(int a, int b) { int x = a + b; use(x); }`); + const x = bindingIdx(cfg, 'x'); + // x is defined somewhere… + const defined = cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(x))); + expect(defined).toBe(true); + // …and used somewhere. + const used = cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(x))); + expect(used).toBe(true); + }); + + it('if (a && (x = f())) records x as a MAY-def, not a must-kill', () => { + const cfg = c.cfgOf(`void f(int a) { int x = 0; if (a && (x = g())) h(x); }`); + const x = bindingIdx(cfg, 'x'); + const hasMayDef = cfg.blocks.some((bl) => + bl.statements?.some((s) => (s.mayDefs ?? []).includes(x)), + ); + expect(hasMayDef).toBe(true); + }); +}); + +describe('C CfgVisitor — functionStartColumn', () => { + it('two same-line functions get distinct functionStartColumn', () => { + const cfgs = c.cfgsOf(`int a(){return 1;} int b(){return 2;}`); + expect(cfgs).toHaveLength(2); + expect(cfgs[0].functionStartLine).toBe(cfgs[1].functionStartLine); // same line + expect(cfgs[0].functionStartColumn).not.toBe(cfgs[1].functionStartColumn); // distinct column + }); +}); + +describe('C++ CfgVisitor — exceptions', () => { + it('try/catch: a throw edge runs from each protected block to the handler', () => { + const cfg = cpp.cfgOf(`void f() { + try { risky(); deeper(); } catch (std::exception& e) { handle(e); } + after(); + }`); + expect(edgeKinds(cfg).has('throw')).toBe(true); + const handler = block(cfg, 'handle(e);'); + // every protected-region block reaches the handler. + expect(reaches(cfg, block(cfg, 'risky();'), handler)).toBe(true); + // and after() is still reachable (handler completion rejoins). + expect(reachable(cfg, block(cfg, 'after();'))).toBe(true); + }); + + it('throw inside a branched try body reaches the handler from the interior block', () => { + const cfg = cpp.cfgOf(`void f(int x) { + try { guard(); if (x) { deep(); } } catch (int e) { onErr(); } + }`); + const handler = block(cfg, 'onErr();'); + expect(reaches(cfg, block(cfg, 'deep();'), handler)).toBe(true); + }); + + it('throw with NO enclosing try routes to EXIT and ends its block', () => { + const cfg = cpp.cfgOf(`void f(int x) { if (x) { throw 1; } done(); }`); + const thr = block(cfg, 'throw 1;'); + expect(cfg.edges).toContainEqual({ from: thr, to: cfg.exitIndex, kind: 'throw' }); + // throw terminates its block — control does not fall into done() from it. + expect(reaches(cfg, thr, block(cfg, 'done();'))).toBe(false); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); // via the if false branch + }); +}); + +describe('C++ CfgVisitor — range-for', () => { + it('for_range_loop: header + body + loop-back + exit', () => { + const cfg = cpp.cfgOf(`void f(std::vector& xs) { for (int x : xs) { use(x); } done(); }`); + const body = block(cfg, 'use(x);'); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + // the loop body loops back to the header and the loop has an exit to done(). + const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to; + expect(header).toBeDefined(); + expect(reachable(cfg, block(cfg, 'done();'))).toBe(true); + expect(exitReachableFromAll(cfg)).toBe(true); + }); + + it('range-for declarator defines the loop variable; iterated expr is a use', () => { + const cfg = cpp.cfgOf(`void f(std::vector& xs) { for (int x : xs) { use(x); } }`); + const x = bindingIdx(cfg, 'x'); + const defined = cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(x))); + expect(defined).toBe(true); + }); +}); + +describe('C++ CfgVisitor — lambdas are CFG-bearing functions', () => { + it('a lambda body yields its own well-formed CFG', () => { + const cfgs = cpp.cfgsOf(`void f() { auto g = [](int x) { if (x) { a(); } return x; }; }`); + // f and the lambda are both CFG-bearing. + expect(cfgs.length).toBeGreaterThanOrEqual(2); + for (const cfg of cfgs) { + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + } + }); +});