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) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 10:59:00 +00:00
parent bc7cc3ffc8
commit 018635074a
6 changed files with 1745 additions and 0 deletions

View file

@ -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<string, number>;
}
export class CCppHarvester {
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.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<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
addMayDef(idx: number): void {
if (this.mayDefSeen.has(idx)) return;
this.mayDefSeen.add(idx);
this.mayDefs.push(idx);
}
addUse(idx: number): void {
if (this.useSeen.has(idx)) return;
this.useSeen.add(idx);
this.uses.push(idx);
}
defCount(): number {
return this.defs.length + this.mayDefs.length;
}
finish(): StatementFacts {
return {
line: this.line,
defs: this.defs,
uses: this.uses,
...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}),
};
}
}

View file

@ -0,0 +1,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<string, number>();
/** Pending gotos to a label not yet seen: label → list of source blocks. */
protected readonly pendingGotos = new Map<string, number[]>();
/** Set of node types that break a block, used by {@link visitSeq}. */
protected readonly controlFlowTypes: ReadonlySet<string>;
constructor(
protected readonly builder: CfgBuilder,
protected readonly harvest: CCppHarvester,
controlFlowTypes: ReadonlySet<string>,
) {
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<string>,
controlFlowTypes: ReadonlySet<string>,
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<SyntaxNode> {
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<SyntaxNode> {
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 };

View file

@ -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<string> = 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

View file

@ -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;
}

View file

@ -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 <vector>
#include <stdexcept>
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<int> &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();
}
}

View file

@ -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<typeof makeCfgHarness>[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<string> => new Set(cfg.edges.map((e) => e.kind));
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
const seen = new Set([from]);
const stack = [from];
while (stack.length) {
const n = stack.pop() as number;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
return seen.has(to);
}
const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx);
/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */
function exitReachableFromAll(cfg: FunctionCfg): boolean {
for (const b of cfg.blocks) {
if (b.index === cfg.exitIndex) continue;
if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt
if (!reaches(cfg, b.index, cfg.exitIndex)) return false;
}
return true;
}
/** Resolve a binding by name → its index in the function's binding table. */
function bindingIdx(cfg: FunctionCfg, name: string): number {
const i = (cfg.bindings ?? []).findIndex((b) => b.name === name);
if (i < 0) throw new Error(`no binding ${name}`);
return i;
}
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<int>& 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<int>& 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);
}
});
});