feat(cfg): C# CFG visitor + def/use harvest (#2195 U3)

Add createCsharpCfgVisitor + csharp-harvest over the shared CfgBuilder /
ControlFlowContext, modeling the C# statement taxonomy: if/else,
for/foreach/while/do, switch_section (+ switch_expression arms),
try/catch/catch_filter/finally, using + lock (deterministic finalizers --
dispose/release runs on normal AND exception exit, finally-* completion
edges on crossing jumps), goto/labeled, yield (surface only), return/
throw/break/continue. Wire into csharpProvider.

Every literal validated against tree-sitter-c-sharp via the introspection
probe (record_declaration, no else_clause, switch_section, positional
access where no field exists). Edge kinds match the contract;
functionStartColumn populated; while(true) keeps EXIT reverse-reachable
(production CDG probe: 3 edges). buildFunctionCfg returns undefined
rather than throwing.

34 real-parser regression tests; grammar-literal gate green; no
regression (cfg unit dir 256/256, tsc clean). Documented gaps: yield
iterator state machine, goto case/default, async suspension points.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 11:10:25 +00:00
parent 018635074a
commit aad3a702b9
5 changed files with 2002 additions and 0 deletions

View file

@ -0,0 +1,543 @@
/**
* C# def/use harvester (#2195 U3, plan KTD2) — the C# analogue of
* {@link import('./typescript-harvest.js').TsHarvester} and the closely-related
* {@link import('./c-cpp-harvest.js').CCppHarvester}.
*
* Runs in the parse worker next to the 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.
*
* TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the TS / C-C++ harvesters):
* 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:
* - `local_declaration_statement` → `variable_declaration` → `variable_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.), `postfix_unary_expression`
* / `prefix_unary_expression` (`x++` / `--x`) — define and (for compound /
* update) also use the lvalue.
* - parameters (`parameter` → `name` field), the `foreach` loop variable
* (`foreach_statement` field `left`), pattern bindings (`declaration_pattern`
* `name`, e.g. `o is string s` / `case int n:`), and catch-clause names
* (`catch_declaration` `name`).
* EXCLUDED, deliberately (TypeScript-CFA precedent): member / element / pointer
* writes (`obj.F = …`, `a[i] = …`) are NOT scalar defs — their identifiers are
* uses only. Nested-function (lambda / local-function / anonymous-method) bodies
* are opaque in BOTH directions (writes to and reads of captured outer variables
* are invisible).
*
* MAY-DEFS: a def inside a conditionally-evaluated subexpression — the right
* operand of `&&` / `||` / `??` (`a ?? (a = load())`), a ternary arm, or a switch
* arm/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 (fields, properties, statics,
* namespaced names) resolve to a SYNTHETIC module-level binding (`name@module`),
* applied identically by def and use harvesting.
*
* NOTE: nothing serialized here may carry a field named `nodeId` — the durable
* parsedfile-store reviver dedups objects keyed on that field name.
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import type { BindingEntry, StatementFacts } from '../types.js';
/** Node types that own a nested CFG — their subtrees are opaque to harvesting. */
const NESTED_FUNCTION_TYPES = new Set([
'lambda_expression',
'anonymous_method_expression',
'local_function_statement',
'method_declaration',
'constructor_declaration',
]);
/**
* Nodes that open a lexical scope for block-local declarations. A `block` is one
* scope; the loop constructs open a scope for their loop variable; a
* `catch_clause` scopes its exception name; a `using_statement` scopes its
* resource declaration; a `switch_section` scopes its pattern bindings.
*/
const SCOPE_TYPES = new Set([
'block',
'for_statement',
'foreach_statement',
'while_statement',
'using_statement',
'catch_clause',
'switch_section',
'switch_expression_arm',
]);
interface Scope {
readonly parent: Scope | null;
/** name → binding index */
readonly table: Map<string, number>;
}
export class CsharpHarvester {
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 node (a `block` or an expression for `=> expr`). */
private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined {
const body = fnNode.childForFieldName('body');
if (body) return body;
// Anonymous method / local function: the body is the first `block` child.
return fnNode.namedChildren.find((c) => c.type === 'block');
}
// ── 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,
});
}
private declareParams(fnNode: SyntaxNode): void {
const params =
fnNode.childForFieldName('parameters') ??
fnNode.namedChildren.find(
(c) => c.type === 'parameter_list' || c.type === 'implicit_parameter',
);
if (!params) return;
if (params.type === 'implicit_parameter') {
// Single un-parenthesized lambda parameter: `x => …`.
this.declare(params, 'param', this.root);
return;
}
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter') continue;
const name = p.childForFieldName('name');
if (name) this.declare(name, 'param', this.root);
}
}
private prescan(node: SyntaxNode, scope: Scope): void {
this.nearestScopeCache.set(node.id, scope);
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// A nested function / lambda body is opaque — do not descend.
return;
}
let childScope = scope;
if (SCOPE_TYPES.has(t)) childScope = this.openScope(node);
switch (t) {
case 'local_declaration_statement': {
const decl = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (decl) this.declareVariableDeclaration(decl, childScope);
break;
}
case 'foreach_statement': {
// `foreach (var x in xs)` — the `left` is the loop var (identifier or a
// `tuple_pattern` of identifiers); binds in the loop scope.
const left = node.childForFieldName('left');
if (left) this.declareForeachTarget(left, childScope);
break;
}
case 'using_statement': {
// `using (var f = Open())` — declaration form binds the resource.
const decl = node.namedChildren.find((c) => c.type === 'variable_declaration');
if (decl) this.declareVariableDeclaration(decl, childScope);
break;
}
case 'catch_clause': {
const declNode = node.namedChildren.find((c) => c.type === 'catch_declaration');
const name = declNode?.childForFieldName('name');
if (name) this.declare(name, 'catch', childScope);
break;
}
case 'declaration_pattern': {
// `o is string s` / `case int n:` — `s`/`n` is a fresh binding.
const name = node.childForFieldName('name');
if (name) this.declare(name, 'var', childScope);
break;
}
default:
break;
}
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.prescan(c, childScope);
}
}
/** Declare every `variable_declarator` name in a `variable_declaration`. */
private declareVariableDeclaration(declNode: SyntaxNode, scope: Scope): void {
for (let i = 0; i < declNode.namedChildCount; i++) {
const d = declNode.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
if (name) this.declare(name, 'var', scope);
}
}
/** Declare a `foreach` target — an identifier or a `tuple_pattern`. */
private declareForeachTarget(left: SyntaxNode, scope: Scope): void {
if (left.type === 'identifier') {
this.declare(left, 'var', scope);
return;
}
// `var (k, v)` deconstruction — declare each identifier under the pattern.
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.declare(c, '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 `foreach (decl in right)` head: decl binds, right is used. */
forEachHeadFacts(stmt: SyntaxNode): StatementFacts {
const acc = new FactAccumulator(stmt.startPosition.row + 1);
const left = stmt.childForFieldName('left');
const right = stmt.childForFieldName('right');
if (left) this.defForeachTarget(left, acc);
if (right) this.walkValue(right, acc);
return acc.finish();
}
/** ENTRY-block facts for the function's parameters (defs only). */
paramFacts(): StatementFacts | undefined {
const params =
this.fnNode.childForFieldName('parameters') ??
this.fnNode.namedChildren.find(
(c) => c.type === 'parameter_list' || c.type === 'implicit_parameter',
);
if (!params) return undefined;
const acc = new FactAccumulator(this.fnNode.startPosition.row + 1);
if (params.type === 'implicit_parameter') {
this.def(params, acc);
} else {
for (let i = 0; i < params.namedChildCount; i++) {
const p = params.namedChild(i);
if (p?.type !== 'parameter') continue;
const name = p.childForFieldName('name');
if (name) this.def(name, acc);
}
}
return acc.defCount() ? acc.finish() : undefined;
}
/** Def fact for a `catch (T e)` declaration — prepend to the handler entry block. */
catchParamFacts(catchClause: SyntaxNode): StatementFacts | undefined {
const declNode = catchClause.namedChildren.find((c) => c.type === 'catch_declaration');
const name = declNode?.childForFieldName('name');
if (!name) return undefined;
const acc = new FactAccumulator(catchClause.startPosition.row + 1);
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;
}
/** Def a `foreach` target (identifier or tuple) in a header fact accumulator. */
private defForeachTarget(left: SyntaxNode, acc: FactAccumulator): void {
if (left.type === 'identifier') {
this.def(left, acc);
return;
}
for (let i = 0; i < left.namedChildCount; i++) {
const c = left.namedChild(i);
if (c?.type === 'identifier') this.def(c, acc);
}
}
/** Value-position walk: collect uses; route def positions to the lvalue handler. */
private walkValue(node: SyntaxNode, acc: FactAccumulator): void {
const t = node.type;
if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) {
// Opaque nested function / lambda — captured reads/writes are invisible.
return;
}
switch (t) {
case 'identifier':
this.use(node, acc);
return;
case 'local_declaration_statement':
case 'variable_declaration': {
const decl = t === 'variable_declaration' ? node : node.namedChild(0);
if (decl && decl.type === 'variable_declaration') {
for (let i = 0; i < decl.namedChildCount; i++) {
const d = decl.namedChild(i);
if (d?.type !== 'variable_declarator') continue;
const name = d.childForFieldName('name');
// The initializer (if any) is the LAST named child after `name`.
const init = this.declaratorInit(d);
if (name && init) this.def(name, acc);
if (init) this.walkValue(init, 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 if (lv.type === 'tuple_expression') {
this.defTupleTargets(lv, acc); // `(a, b) = …` deconstruction
} else {
this.walkValue(lv, acc); // member/element target — uses only
}
}
if (right) this.walkValue(right, acc);
return;
}
case 'postfix_unary_expression':
case 'prefix_unary_expression': {
const arg = node.namedChild(0);
const lv = arg ? this.unwrapLvalue(arg) : null;
// Only `++`/`--` write; `!x`/`-x`/`~x` are pure reads. The operator is an
// anonymous child; treat as a def+use only when the operand is an
// identifier AND the op text is an increment/decrement.
if (lv?.type === 'identifier' && this.isIncDec(node)) {
this.def(lv, acc);
this.use(lv, 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 === '||' || 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 'member_access_expression': {
// `a.B` — value read of the chain root only; the member name is not a
// scalar binding. Mirrors the TS member-read use semantics.
const expr = node.childForFieldName('expression');
if (expr) this.walkValue(expr, acc);
return;
}
default:
for (let i = 0; i < node.namedChildCount; i++) {
const c = node.namedChild(i);
if (c) this.walkValue(c, acc);
}
}
}
/** The initializer value of a `variable_declarator` — the named child after `name`. */
private declaratorInit(declarator: SyntaxNode): SyntaxNode | undefined {
const name = declarator.childForFieldName('name');
for (let i = 0; i < declarator.namedChildCount; i++) {
const c = declarator.namedChild(i);
if (c && c.id !== name?.id) return c;
}
return undefined;
}
/** Whether a unary expression is `++`/`--` (the only writing unary ops). */
private isIncDec(node: SyntaxNode): boolean {
for (let i = 0; i < node.childCount; i++) {
const c = node.child(i);
if (c && !c.isNamed && (c.text === '++' || c.text === '--')) return true;
}
return false;
}
/** Def each identifier in a `(a, b) = …` tuple deconstruction target. */
private defTupleTargets(tuple: SyntaxNode, acc: FactAccumulator): void {
for (let i = 0; i < tuple.namedChildCount; i++) {
const c = tuple.namedChild(i);
if (!c) continue;
// tuple_expression wraps each element in an `argument`.
const inner = c.type === 'argument' ? c.namedChild(0) : c;
if (inner?.type === 'identifier') this.def(inner, acc);
else if (inner) this.walkValue(inner, 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,909 @@
/**
* C# CfgVisitor (#2195 U3, plan KTD2).
*
* Walks a 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 CsharpHarvester}) for the reaching-defs / CDG solvers.
* Structured like the TS visitor — a `visit_<node_type>` dispatch over the
* statement taxonomy, driving a per-function {@link ControlFlowContext} — because
* C# shares TS's `finally` semantics (try/finally AND `using`/`lock`-as-finally),
* which the finalizer-frame machinery in `control-flow-context.ts` models.
*
* Every node type and field literal below was grammar-validated against
* tree-sitter-c-sharp via the introspection probe before use (mandatory pre-step,
* KTD5). Known C# surprises pre-empted: records are `record_declaration` (NOT
* `record_struct_declaration`); there is no `else_clause` wrapper (an
* `if_statement`'s `alternative` is the else body / nested `if` directly); switch
* cases live in `switch_section`s under a `switch_body`; `variable_declarator`
* exposes only a `name` field (the initializer is a positional child);
* `finally_clause` / `lock_statement` / `catch_filter_clause` expose no field for
* their body/condition (positional children).
*
* Function nodes: `method_declaration`, `local_function_statement`,
* `constructor_declaration`, `lambda_expression`, `anonymous_method_expression`,
* and expression-bodied members (`body` is an `arrow_expression_clause`).
*
* Edge-kind contract (matches the TS visitor — RD/CDG consume these):
* - if/else → `cond-true` / `cond-false`
* - loops (for / foreach / while / do-while) → `cond-true` / `loop-back` /
* `cond-false`
* - switch (`switch_statement`) → `switch-case` / `fallthrough`; a
* `switch_expression` arm dispatches as `switch-case` (each arm a guarded
* branch, no fallthrough)
* - try/catch → `throw` (every protected-region block → the handler); a jump
* crossing a `finally` → `finally-return` / `finally-break` / `finally-continue`
* - `using` / `lock` → modeled like try/finally: the dispose (`using`) runs on
* BOTH normal and exception exit (deterministic), so a `return`/`break`/
* `continue` crossing it gets the `finally-*` completion edge too. `lock`'s
* monitor-release is likewise a deterministic finalizer.
* - return / throw / break / continue → the matching terminator kind
* - straight-line → `seq`
*
* Classic hazards, handled explicitly (mirrors the TS visitor):
* - 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 (true) {}` still emit the structural `header →
* loopExit` `cond-false` escape edge so EXIT stays reverse-reachable from
* every block — the post-dominator / CDG pass silently emits zero CDG for the
* function otherwise.
* - labeled `goto`: labels resolve within the function (forward AND backward);
* an unresolved `goto` (incl. `goto case`/`goto default`, which target a
* switch arm this CFG does not label) routes to EXIT and logs, preserving
* single-exit.
* - 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.
*
* Known limitations:
* - `yield return` / `yield break`: C# iterator methods compile to a hidden
* state machine; tree-sitter shows only the surface `yield_statement`. This
* visitor models `yield break` as a terminator → EXIT (`return`) and
* `yield return e` as a block whose value continues to the next statement
* (the producer resumes after the consumer pulls). The real suspend/resume
* state-machine control flow is NOT modeled — documented gap, not faked.
* - `goto case L;` / `goto default;` have no statically-labeled CFG target
* (switch arms are not labeled blocks here) and route to EXIT like an
* unresolved label.
* - Async/await suspension points are modeled as straight-line (the awaited
* continuation is not a separate flow), consistent with the TS visitor.
* - Def/use harvest scope: see `csharp-harvest.ts` — member/element writes are
* not scalar defs; nested-function bodies are opaque in both directions.
*
* Returns `undefined` (never throws) for an AST shape it cannot model, so a
* malformed function never drops the whole file's CFG group (R4).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import {
ControlFlowContext,
drainFinalizerPending,
wireJumpThroughFinalizers,
} from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
import { CsharpHarvester } from './csharp-harvest.js';
/** C# node types that own a CFG-bearing function body. */
const CSHARP_FUNCTION_TYPES = new Set([
'method_declaration',
'local_function_statement',
'constructor_declaration',
'lambda_expression',
'anonymous_method_expression',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'while_statement',
'do_statement',
'for_statement',
'foreach_statement',
'switch_statement',
'try_statement',
'using_statement',
'lock_statement',
'return_statement',
'break_statement',
'continue_statement',
'throw_statement',
'goto_statement',
'labeled_statement',
'yield_statement',
'block',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function C# walk state. One instance per function so the
* {@link ControlFlowContext}, exception-handler stack, and label tables are
* scoped to that function and never leak across functions.
*/
class CsharpCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
private readonly handlers: number[] = [];
/** label name → its `labeled_statement` body's entry block (resolved on demand). */
private readonly labelBlocks = new Map<string, number>();
/** Pending gotos to a label not yet seen: label → list of source blocks. */
private readonly pendingGotos = new Map<string, number[]>();
constructor(
private readonly builder: CfgBuilder,
private readonly harvest: CsharpHarvester,
) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first `block` child). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'block');
}
/** Visit a body that may be a `block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
if (openSimple === undefined) {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
if (entry === undefined) entry = idx;
else this.builder.connect(dangling, idx, 'seq');
openSimple = idx;
dangling = [idx];
} else {
this.builder.extendBlock(openSimple, endLineOf(stmt), stmt.text, this.harvest.facts(stmt));
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'foreach_statement':
return this.visitForEach(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'using_statement':
return this.visitUsing(stmt);
case 'lock_statement':
return this.visitLock(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'throw_statement':
return this.visitThrow(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 'yield_statement':
return this.visitYield(stmt);
case 'block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
return { entry: idx, exits: [idx] };
}
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
// A return crosses EVERY active finally (try/using/lock) before EXIT.
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
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: [] };
}
/** `yield return e;` continues; `yield break;` terminates the iterator. */
private visitYield(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(
startLineOf(stmt),
endLineOf(stmt),
stmt.text,
'normal',
this.harvest.facts(stmt),
);
// `yield break;` (no value child) ends iteration → EXIT (modeled like return);
// `yield return e;` (has a value child) yields one element and CONTINUES, so
// it falls through to the next statement. The hidden iterator state machine
// is not modeled (documented limitation).
if (this.isYieldBreak(stmt)) {
wireJumpThroughFinalizers(
this.builder,
idx,
this.cfc.finalizersForReturn(),
this.builder.exitIndex,
'return',
);
return { entry: idx, exits: [] };
}
return { entry: idx, exits: [idx] };
}
/** A `yield break;` has no value child; `yield return e;` has one. */
private isYieldBreak(stmt: SyntaxNode): boolean {
return stmt.namedChildCount === 0;
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveBreak(undefined);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const res = this.cfc.resolveContinue(undefined);
const { target, finalizers } = res ?? {
target: this.builder.exitIndex,
finalizers: this.cfc.finalizersForReturn(),
};
wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue');
return { entry: idx, exits: [] };
}
/** `goto label;` — route to the label block if known, else defer / EXIT. */
private visitGoto(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const label = this.gotoLabel(stmt);
if (label === undefined) {
// `goto case L;` / `goto default;` — no statically-labeled CFG target.
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 {
const list = this.pendingGotos.get(label);
if (list) list.push(idx);
else this.pendingGotos.set(label, [idx]);
}
return { entry: idx, exits: [] };
}
private visitLabeled(stmt: SyntaxNode): SeqResult {
// `labeled_statement` = label `identifier` + the labeled statement.
const labelNode = stmt.namedChildren.find((c) => c.type === 'identifier');
const label = labelNode?.text;
const body = stmt.namedChildren.find((c) => c.id !== labelNode?.id && c.type !== 'comment');
const res = this.visitBody(body ?? null);
if (label !== undefined) {
const entry = res?.entry ?? this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.labelBlocks.set(label, entry);
const pending = this.pendingGotos.get(label);
if (pending) {
for (const from of pending) this.builder.edge(from, entry, 'seq');
this.pendingGotos.delete(label);
}
if (!res) return { entry, exits: [entry] };
}
return res;
}
private 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
}
// No `else_clause` wrapper in C#: `alternative` is the else body or the
// nested `if_statement` for an `else if` chain directly.
const elseNode = stmt.childForFieldName('alternative');
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)] };
}
private 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.cfc.pushLoop(header, loopExit);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
// Always emit the structural exit edge — even `while (true)` keeps EXIT
// reverse-reachable for the post-dominator / CDG pass.
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private 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.cfc.pushLoop(condBlock, loopExit);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.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] };
}
private 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.cfc.pushLoop(incrBlock, loopExit);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, incr ? 'seq' : 'loop-back');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
if (!incr) this.builder.edge(header, header, 'loop-back');
}
// Structural exit edge — `for (;;) {}` (no condition) still keeps EXIT
// reverse-reachable so CDG is not silently skipped for the function.
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(
startLineOf(init),
endLineOf(init),
init.text,
'normal',
this.harvest.facts(init),
);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForEach(stmt: SyntaxNode): TraversalResult {
// Header text is SYNTHESIZED, so facts come from the left/right nodes
// directly (the loop variable is a def, the iterated expression a use).
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forEachHeaderText(stmt),
'normal',
this.harvest.forEachHeadFacts(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forEachHeaderText(stmt: SyntaxNode): string {
const left = stmt.childForFieldName('left')?.text ?? '';
const right = stmt.childForFieldName('right')?.text ?? '';
return left || right ? `foreach(${left} in ${right})` : 'foreach(… in …)';
}
private visitSwitch(stmt: SyntaxNode): TraversalResult {
const value = stmt.childForFieldName('value') ?? 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.cfc.pushSwitch(switchExit);
const body = stmt.childForFieldName('body');
const sections = body
? body.namedChildren.filter((c) => c.type === 'switch_section')
: [];
// Each `switch_section`'s case-test patterns/`when` clauses evaluate before
// the body runs — harvest their uses (and any pattern binding as a may-def)
// onto the dispatch block, one record per section, conditionally (a later
// section test only runs when earlier sections didn't match).
for (const sec of sections) {
for (const test of this.sectionTests(sec)) {
this.builder.attachFacts(dispatch, this.harvest.factsConditional(test));
}
}
const sectionResults = sections.map((s) => this.visitSeq(this.sectionStatements(s)));
const hasDefault = sections.some((s) => this.sectionIsDefault(s));
const entryOf: number[] = new Array(sections.length);
let after = switchExit;
for (let i = sections.length - 1; i >= 0; i--) {
entryOf[i] = sectionResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < sections.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
// A non-`break` section's dangling exits fall through to the next section
// (an empty `case 2:` falling into `case 3:`, or `goto case`-less spill).
for (let i = 0; i < sections.length; i++) {
const res = sectionResults[i];
if (!res) continue;
const fallTarget = i + 1 < sections.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.cfc.pop();
return { entry: dispatch, exits: [switchExit] };
}
/** A switch_section's body statements (everything but its case labels/patterns). */
private sectionStatements(section: SyntaxNode): SyntaxNode[] {
return section.namedChildren.filter(
(c) => this.isStatementLike(c) && c.type !== 'comment',
);
}
/** The case-test expressions of a section (constant patterns + when clauses). */
private sectionTests(section: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
for (const c of section.namedChildren) {
if (c.type === 'when_clause') {
const inner = c.namedChild(0);
if (inner) out.push(inner);
} else if (!this.isStatementLike(c) && c.type !== 'comment') {
out.push(c); // a pattern (constant_pattern / declaration_pattern / …)
}
}
return out;
}
/** A `default:` section has only a `default` label (no pattern). */
private sectionIsDefault(section: SyntaxNode): boolean {
return !section.namedChildren.some(
(c) => !this.isStatementLike(c) && c.type !== 'comment' && c.type !== 'when_clause',
);
}
/** Heuristic: a section child is statement-like if it ends a basic-block run. */
private isStatementLike(node: SyntaxNode): boolean {
return node.type.endsWith('_statement') || node.type === 'block';
}
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
const catchClauses: SyntaxNode[] = [];
let finallyClause: SyntaxNode | undefined;
for (let i = 0; i < stmt.namedChildCount; i++) {
const c = stmt.namedChild(i);
if (c?.type === 'catch_clause') catchClauses.push(c);
else if (c?.type === 'finally_clause') finallyClause = c;
}
const finallyBody = finallyClause?.namedChildren.find((c) => c.type === 'block');
return this.buildProtected(bodyNode ?? null, catchClauses, finallyBody ?? null, stmt);
}
/**
* `using (resource) body` — the dispose runs deterministically on BOTH normal
* AND exception exit, which is exactly `try { body } finally { dispose }`. We
* model the dispose as a synthesized finalizer block (the AST has no Dispose()
* call node), so a `return`/`break`/`continue` crossing it threads through and
* gets a `finally-*` completion edge.
*/
private visitUsing(stmt: SyntaxNode): SeqResult {
const bodyNode = this.bodyBlockOf(stmt) ?? null;
// The resource (declaration or expression) is the named child before `body`.
const resource = this.usingResource(stmt);
const disposeFacts = resource ? this.harvest.facts(resource) : undefined;
return this.buildProtectedSynthetic(
bodyNode,
stmt,
'dispose',
disposeFacts,
resource ?? stmt,
);
}
/**
* `lock (obj) body` — the monitor release runs on both normal and exception
* exit (deterministic finalizer), same shape as `using`'s dispose.
*/
private visitLock(stmt: SyntaxNode): SeqResult {
const bodyNode = this.bodyBlockOf(stmt) ?? null;
const lockObj = stmt.namedChildren.find((c) => c.type !== 'block');
const releaseFacts = lockObj ? this.harvest.facts(lockObj) : undefined;
return this.buildProtectedSynthetic(bodyNode, stmt, 'release', releaseFacts, lockObj ?? stmt);
}
/** The `using` resource node (variable_declaration or an expression). */
private usingResource(stmt: SyntaxNode): SyntaxNode | undefined {
const body = stmt.childForFieldName('body');
return stmt.namedChildren.find((c) => c.id !== body?.id && c.type !== 'comment');
}
/**
* Shared try/catch/finally builder. `catchClauses` may be empty; `finallyBody`
* is the finally's `block` (or null). Models the TS `visitTry` semantics:
* normal completion of try AND catch flow through finally; a throw in the
* protected region routes to the handler; early exits crossing the finally
* thread through it (finally-* completion edges).
*/
private buildProtected(
bodyNode: SyntaxNode | null,
catchClauses: SyntaxNode[],
finallyBody: SyntaxNode | null,
span: SyntaxNode,
): SeqResult {
const finallyRes = finallyBody ? this.visitSeq(this.statementsOf(finallyBody)) : null;
const finFrame = finallyRes ? this.cfc.pushFinalizer(finallyRes.entry) : null;
// Build each catch handler.
const catchEntries: number[] = [];
const catchExits: number[] = [];
let firstCatchEntry: number | undefined;
for (const clause of catchClauses) {
const clauseBody = clause.childForFieldName('body');
if (finallyRes) this.handlers.push(finallyRes.entry);
let res: SeqResult = clauseBody
? this.visitSeq(this.statementsOf(clauseBody))
: null;
if (finallyRes) this.handlers.pop();
if (res === null) {
// Empty `catch {}` still catches — synthesize one block so exception
// flow 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 };
}
catchEntries.push(res.entry);
catchExits.push(...res.exits);
if (firstCatchEntry === undefined) firstCatchEntry = res.entry;
}
// Handler for the try body: first catch if present, else finally, else outer.
const tryHandler = firstCatchEntry ?? finallyRes?.entry ?? this.currentHandler();
const protectedStart = this.builder.blockCount;
this.handlers.push(tryHandler);
const bodyRes = bodyNode
? bodyNode.type === 'block'
? this.visitSeq(this.statementsOf(bodyNode))
: this.visitStmt(bodyNode)
: null;
this.handlers.pop();
if (catchClauses.length > 0 || finallyBody) {
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, tryHandler, 'throw');
}
}
if (finFrame && finallyRes) {
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, finallyRes.exits);
}
const exits: number[] = [];
if (finallyRes) {
if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
for (const e of catchExits) this.builder.edge(e, finallyRes.entry, 'seq');
exits.push(...finallyRes.exits);
// No catch → an exception re-propagates out after finally runs.
if (catchClauses.length === 0) {
this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
}
} else {
if (bodyRes) exits.push(...bodyRes.exits);
exits.push(...catchExits);
}
const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchEntries[0];
if (entry === undefined) {
void span;
return null;
}
return { entry, exits: [...new Set(exits)] };
}
/**
* `using`/`lock`: a protected body whose finalizer is a SYNTHESIZED single
* block (Dispose() / Monitor.Exit() have no AST node). The finalizer runs on
* both normal and exception exit, and crossing jumps thread through it.
*/
private buildProtectedSynthetic(
bodyNode: SyntaxNode | null,
span: SyntaxNode,
text: string,
finalizerFacts: StatementFactsLike,
factsNode: SyntaxNode,
): SeqResult {
void factsNode;
const finalizerBlock = this.builder.newBlock(
endLineOf(span),
endLineOf(span),
text,
'normal',
finalizerFacts ?? undefined,
);
const finRes: TraversalResult = { entry: finalizerBlock, exits: [finalizerBlock] };
const finFrame = this.cfc.pushFinalizer(finRes.entry);
const protectedStart = this.builder.blockCount;
// The finalizer IS the handler — an exception in the body still runs dispose,
// which then re-propagates to the outer handler.
this.handlers.push(finRes.entry);
const bodyRes = bodyNode
? bodyNode.type === 'block'
? this.visitSeq(this.statementsOf(bodyNode))
: this.visitStmt(bodyNode)
: null;
this.handlers.pop();
for (let b = protectedStart; b < this.builder.blockCount; b++) {
this.builder.edge(b, finRes.entry, 'throw');
}
this.cfc.pop();
drainFinalizerPending(this.builder, finFrame, finRes.exits);
// Normal completion of the body flows through the finalizer; the finalizer's
// exit re-propagates an exception to the outer handler (it had no catch).
if (bodyRes) this.builder.connect(bodyRes.exits, finRes.entry, 'seq');
this.builder.connect(finRes.exits, this.currentHandler(), 'throw');
const entry = bodyRes?.entry ?? finRes.entry;
return { entry, exits: [...finRes.exits] };
}
/** Nearest enclosing exception handler, or the function EXIT. */
private currentHandler(): number {
return this.handlers.length ? this.handlers[this.handlers.length - 1] : this.builder.exitIndex;
}
/** The target label of a `goto label;` (undefined for `goto case`/`goto default`). */
private gotoLabel(stmt: SyntaxNode): string | undefined {
const id = stmt.namedChildren.find((c) => c.type === 'identifier');
return id?.text;
}
}
/** A pre-built {@link StatementFacts} record, or undefined when none. */
type StatementFactsLike = ReturnType<CsharpHarvester['facts']> | undefined;
/** Build the CFG for one C# function node, or `undefined` if not modelable. */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
try {
if (!CSHARP_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const startColumn = fnNode.startPosition.column;
// The body is a `block` (field `body`) OR an `arrow_expression_clause`
// (expression-bodied member) OR an expression (single-expression lambda).
const body =
fnNode.childForFieldName('body') ??
fnNode.namedChildren.find((c) => c.type === 'block');
if (!body) return undefined; // abstract / partial / interface member — no body
const builder = new CfgBuilder(filePath, startLine, endLine, startColumn);
const harvest = new CsharpHarvester(fnNode);
const paramFacts = harvest.paramFacts();
if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts);
if (body.type === 'arrow_expression_clause' || body.type !== 'block') {
// Expression-bodied member / single-expression lambda: one block whose
// value is returned. For an arrow clause the value is its inner expression.
const expr =
body.type === 'arrow_expression_clause' ? (body.namedChild(0) ?? body) : body;
const blk = builder.newBlock(
startLineOf(expr),
endLineOf(expr),
expr.text,
'normal',
harvest.facts(expr),
);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish(harvest.table());
}
const walk = new CsharpCfgWalk(builder, harvest);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
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# buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`);
return undefined;
}
}
/** Whether a node is a C# function this visitor builds a CFG for. */
function isFunction(node: SyntaxNode): boolean {
return CSHARP_FUNCTION_TYPES.has(node.type);
}
/** The C# CFG visitor. */
export function createCsharpCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { CSHARP_FUNCTION_TYPES };

View file

@ -24,6 +24,7 @@ import { createMethodExtractor } from '../method-extractors/generic.js';
import { csharpMethodConfig } from '../method-extractors/configs/csharp.js';
import { createVariableExtractor } from '../variable-extractors/generic.js';
import { csharpVariableConfig } from '../variable-extractors/configs/csharp.js';
import { createCsharpCfgVisitor } from '../cfg/visitors/csharp.js';
import {
emitCsharpScopeCaptures,
interpretCsharpImport,
@ -200,6 +201,7 @@ export const csharpProvider = defineLanguage({
// the full per-hook rationale and the canonical capture vocabulary
// in ./csharp/query.ts (CSHARP_SCOPE_QUERY constant).
emitScopeCaptures: emitCsharpScopeCaptures,
cfgVisitor: createCsharpCfgVisitor(),
interpretImport: interpretCsharpImport,
interpretTypeBinding: interpretCsharpTypeBinding,
bindingScopeFor: csharpBindingScopeFor,

View file

@ -0,0 +1,159 @@
// C# CFG hazard fixture (#2195 U3). Exercises every control-flow construct the
// csharp CfgVisitor models, so the worker-mode pipeline produces non-trivial
// BasicBlock / CFG / REACHING_DEF / CDG counts (and so the byte-identical-off
// golden gate has real shapes to compare). Mirrors c-hazards.c / cpp-hazards.cpp.
using System;
using System.Collections.Generic;
namespace Hazards
{
public class Demo
{
// if / else-if / else.
public int Classify(int x)
{
if (x > 0)
{
return 1;
}
else if (x < 0)
{
return -1;
}
else
{
return 0;
}
}
// for, foreach, while, do-while + break / continue.
public int Loops(int[] xs, int n)
{
int total = 0;
for (int i = 0; i < n; i++)
{
if (i == 3) { continue; }
total += i;
}
foreach (var x in xs)
{
if (x < 0) { break; }
total += x;
}
int j = 0;
while (j < n)
{
total += j;
j++;
}
do
{
total -= 1;
} while (total > 100);
return total;
}
// switch_statement with fallthrough-empty section + default.
public string Name(int code)
{
switch (code)
{
case 1:
case 2:
return "low";
case 3:
return "three";
default:
return "other";
}
}
// switch_expression arms.
public int Score(int grade) => grade switch
{
1 => 100,
2 => 80,
_ => 0,
};
// try / catch / finally with a return crossing the finally.
public int Guarded(int x)
{
try
{
if (x < 0) { throw new ArgumentException(nameof(x)); }
return Compute(x);
}
catch (ArgumentException e)
{
Log(e);
return -1;
}
finally
{
Cleanup();
}
}
// using (deterministic dispose on both normal and exception exit).
public int ReadAll(string path)
{
using (var reader = Open(path))
{
return reader.Read();
}
}
// lock (monitor release finalizer).
private readonly object _sync = new object();
public void Touch(int v)
{
lock (_sync)
{
_value += v;
}
}
// goto / labeled statement.
public int Retry(int limit)
{
int attempts = 0;
start:
attempts++;
if (attempts < limit) { goto start; }
return attempts;
}
// yield iterator (state-machine limitation documented; surface flow only).
public IEnumerable<int> Take(int[] src, int count)
{
int taken = 0;
foreach (var item in src)
{
if (taken >= count) { yield break; }
taken++;
yield return item;
}
}
// null-coalescing may-def + local function.
public int Resolve(string s)
{
int Parse(string v) => int.Parse(v);
string chosen = s ?? (s = "0");
return Parse(chosen);
}
private int _value;
private int Compute(int x) => x * 2;
private static void Log(Exception e) { }
private void Cleanup() { }
private Reader Open(string path) => new Reader();
}
public class Reader
{
public int Read() => 0;
}
}

View file

@ -0,0 +1,389 @@
import { describe, it, expect, vi } from 'vitest';
import { createRequire } from 'node:module';
import { createCsharpCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/csharp.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js';
// U3 — the 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.
// tree-sitter-c-sharp declares `main: "bindings/node"` (no extension) — load the
// explicit subpath, mirroring parser-loader.ts (#1013).
const csGrammar = createRequire(import.meta.url)(
'tree-sitter-c-sharp/bindings/node/index.js',
) as Parameters<typeof makeCfgHarness>[0];
const cs: CfgHarness = makeCfgHarness(csGrammar, createCsharpCfgVisitor(), 'fixture.cs');
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;
}
const hasDef = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.defs.includes(idx)));
const hasUse = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => s.uses.includes(idx)));
const hasMayDef = (cfg: FunctionCfg, idx: number): boolean =>
cfg.blocks.some((bl) => bl.statements?.some((s) => (s.mayDefs ?? []).includes(idx)));
const wrap = (body: string): string => `class C { void M(${''}) { ${body} } }`;
describe('C# CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT (seq)', () => {
const cfg = cs.cfgOf(`class C { void M() { 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 = cs.cfgOf(`class C { void M() {} }`);
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('expression-bodied method: single block returns its value', () => {
const cfg = cs.cfgOf(`class C { int M(int n) => n * 2; }`);
const body = block(cfg, 'n * 2');
expect(cfg.edges).toContainEqual({ from: body, to: cfg.exitIndex, kind: 'return' });
});
it('constructor and local function are CFG-bearing functions', () => {
const cfgs = cs.cfgsOf(`class C { C(int a) { x = a; } void Outer() { int L(int z) { return z; } L(1); } }`);
// constructor C, Outer, and the local function L = 3 CFGs.
expect(cfgs.length).toBeGreaterThanOrEqual(3);
for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('unmodeled member shape (no body) → graceful partial, no throw', () => {
// An abstract method has no body block → buildFunctionCfg returns undefined,
// never throws; a property (not a function) is not collected at all.
const root = cs.parse(`abstract class C { public abstract void M(); int P => 1; }`);
const fns = cs.collectFunctions(root);
for (const fn of fns) {
expect(() => createCsharpCfgVisitor().buildFunctionCfg(fn, 'f.cs')).not.toThrow();
}
});
});
describe('C# CfgVisitor — branching', () => {
it('if/else: cond-true to then, cond-false to else, both reach the join', () => {
const cfg = cs.cfgOf(wrap(`if (x > 0) { a(); } else { b(); } c();`).replace('M()', 'M(int x)'));
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('else if chains through the nested alternative (no else_clause wrapper)', () => {
const cfg = cs.cfgOf(
`class C { void M(int x) { if (x > 0) { a(); } else if (x < 0) { b(); } else { c(); } } }`,
);
// all three arms reach EXIT; the else-if condition is its own block.
expect(reaches(cfg, block(cfg, 'a();'), cfg.exitIndex)).toBe(true);
expect(reaches(cfg, block(cfg, 'b();'), cfg.exitIndex)).toBe(true);
expect(reaches(cfg, block(cfg, 'c();'), cfg.exitIndex)).toBe(true);
});
});
describe('C# CfgVisitor — loops', () => {
it('while loop: header + back-edge + exit', () => {
const cfg = cs.cfgOf(`class C { void M(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 = cs.cfgOf(`class C { void M(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
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 update', () => {
const cfg = cs.cfgOf(`class C { void M(int n) { 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('foreach: header + body + loop-back + exit; loop var is a def, source a use', () => {
const cfg = cs.cfgOf(`class C { void M(int[] xs) { foreach (var x in 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);
const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to;
expect(header).toBeDefined();
expect(reaches(cfg, header!, block(cfg, 'done();'))).toBe(true);
const x = bindingIdx(cfg, 'x');
expect(hasDef(cfg, x)).toBe(true);
});
it('while (true) {} keeps EXIT reverse-reachable (structural exit-escape edge)', () => {
const cfg = cs.cfgOf(`class C { void M() { while (true) { work(); } } }`);
expect(edgeKinds(cfg).has('cond-false')).toBe(true);
expect(exitReachableFromAll(cfg)).toBe(true);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('for (;;) {} keeps EXIT reverse-reachable', () => {
const cfg = cs.cfgOf(`class C { void M() { for (;;) { work(); } } }`);
expect(edgeKinds(cfg).has('cond-false')).toBe(true);
expect(exitReachableFromAll(cfg)).toBe(true);
});
});
describe('C# CfgVisitor — switch', () => {
it('switch_statement: cases dispatch, break-terminated case rejoins after', () => {
const cfg = cs.cfgOf(`class C { void M(int x) {
switch (x) {
case 1: one(); break;
case 2: two(); break;
default: other(); break;
}
after();
} }`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'after();'))).toBe(true);
expect(reaches(cfg, block(cfg, 'two();'), block(cfg, 'after();'))).toBe(true);
// break-terminated case 1 does not fall into case 2.
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false);
});
it('empty case section falls through to the next section', () => {
const cfg = cs.cfgOf(`class C { void M(int x) {
switch (x) { case 1: case 2: shared(); break; default: d(); break; }
after();
} }`);
// case 1 (empty) reaches the shared body.
expect(reaches(cfg, block(cfg, 'shared();'), block(cfg, 'after();'))).toBe(true);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
});
it('switch_expression arms each dispatch as a guarded branch (switch-case)', () => {
const cfg = cs.cfgOf(`class C { int M(int x) { return x switch { 1 => a(), 2 => b(), _ => c() }; } }`);
// The switch-expression lives inside the return block — it does not break a
// basic block, but the function still has a well-formed single-exit CFG.
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
expect(edgeKinds(cfg).has('return')).toBe(true);
});
});
describe('C# CfgVisitor — using / lock (deterministic finalizer)', () => {
it('using runs the body then dispose on the NORMAL exit path', () => {
const cfg = cs.cfgOf(`class C { void M() { using (var f = Open()) { read(f); } after(); } }`);
const body = block(cfg, 'read(f);');
const dispose = block(cfg, 'dispose');
// body → dispose → after() (normal completion threads through the dispose).
expect(reaches(cfg, body, dispose)).toBe(true);
expect(reaches(cfg, dispose, block(cfg, 'after();'))).toBe(true);
});
it('using disposes on the EXCEPTION path too (throw routes through dispose)', () => {
const cfg = cs.cfgOf(`class C { void M() { using (var f = Open()) { risky(f); } } }`);
const body = block(cfg, 'risky(f);');
const dispose = block(cfg, 'dispose');
// a throw in the protected body reaches the dispose finalizer.
expect(edgeKinds(cfg).has('throw')).toBe(true);
expect(reaches(cfg, body, dispose)).toBe(true);
});
it('a return inside using crosses the dispose (finally-return completion edge)', () => {
const cfg = cs.cfgOf(`class C { int M() { using (var f = Open()) { return read(f); } } }`);
expect(edgeKinds(cfg).has('finally-return')).toBe(true);
});
it('lock body runs then releases the monitor (finalizer semantics)', () => {
const cfg = cs.cfgOf(`class C { void M(object sync) { lock (sync) { touch(); } after(); } }`);
const body = block(cfg, 'touch();');
const release = block(cfg, 'release');
expect(reaches(cfg, body, release)).toBe(true);
expect(reaches(cfg, release, block(cfg, 'after();'))).toBe(true);
});
});
describe('C# CfgVisitor — try/catch/finally completion edges', () => {
it('try/catch: a throw edge runs from each protected block to the handler', () => {
const cfg = cs.cfgOf(`class C { void M() {
try { risky(); deeper(); } catch (System.Exception e) { handle(e); }
after();
} }`);
expect(edgeKinds(cfg).has('throw')).toBe(true);
const handler = block(cfg, 'handle(e);');
expect(reaches(cfg, block(cfg, 'risky();'), handler)).toBe(true);
expect(reachable(cfg, block(cfg, 'after();'))).toBe(true);
});
it('finally runs on normal completion of the try', () => {
const cfg = cs.cfgOf(`class C { void M() {
try { work(); } finally { cleanup(); }
after();
} }`);
const body = block(cfg, 'work();');
const fin = block(cfg, 'cleanup();');
expect(reaches(cfg, body, fin)).toBe(true);
expect(reaches(cfg, fin, block(cfg, 'after();'))).toBe(true);
});
it('return crossing a finally emits a finally-return completion edge', () => {
const cfg = cs.cfgOf(`class C { int M() {
try { return compute(); } finally { cleanup(); }
} }`);
expect(edgeKinds(cfg).has('finally-return')).toBe(true);
});
it('break crossing a finally emits a finally-break completion edge', () => {
const cfg = cs.cfgOf(`class C { void M(int n) {
for (int i = 0; i < n; i++) {
try { if (done()) break; } finally { tick(); }
}
after();
} }`);
expect(edgeKinds(cfg).has('finally-break')).toBe(true);
});
});
describe('C# CfgVisitor — goto / labels', () => {
it('backward goto wires to an already-seen label block', () => {
const cfg = cs.cfgOf(`class C { void M() { int i = 0; top: work(); i++; if (i < 10) goto top; done(); } }`);
const gotoB = block(cfg, 'goto top;');
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 = cs.cfgOf(`class C { void M(int x) { if (x > 0) goto end; work(); end: done(); } }`);
const gotoB = block(cfg, 'goto end;');
const label = block(cfg, 'done();');
expect(reaches(cfg, gotoB, label)).toBe(true);
expect(reachable(cfg, block(cfg, 'work();'))).toBe(true);
});
});
describe('C# CfgVisitor — yield', () => {
it('yield break terminates the iterator (routes to EXIT)', () => {
const cfg = cs.cfgOf(`class C { System.Collections.Generic.IEnumerable<int> G(int x) {
if (x < 0) { yield break; }
yield return x;
} }`);
const yb = block(cfg, 'yield break;');
expect(reaches(cfg, yb, cfg.exitIndex)).toBe(true);
// yield break terminates its block — does not fall into yield return.
expect(reaches(cfg, yb, block(cfg, 'yield return x;'))).toBe(false);
});
it('yield return continues to the next statement', () => {
const cfg = cs.cfgOf(`class C { System.Collections.Generic.IEnumerable<int> G() {
yield return 1;
done();
} }`);
const yr = block(cfg, 'yield return 1;');
expect(reaches(cfg, yr, block(cfg, 'done();'))).toBe(true);
});
});
describe('C# CfgVisitor — def/use harvest', () => {
it('local declaration: int x = a + b; use(x); → def of x + use of x', () => {
const cfg = cs.cfgOf(`class C { void M(int a, int b) { int x = a + b; use(x); } }`);
const x = bindingIdx(cfg, 'x');
expect(hasDef(cfg, x)).toBe(true);
expect(hasUse(cfg, x)).toBe(true);
});
it('c ?? (c = load()) records c as a MAY-def, not a must-kill', () => {
const cfg = cs.cfgOf(`class C { void M(string c) { var v = c ?? (c = load()); use(v); } }`);
const c = bindingIdx(cfg, 'c');
expect(hasMayDef(cfg, c)).toBe(true);
});
it('a && (x = f()) records x as a may-def inside the short-circuit', () => {
const cfg = cs.cfgOf(`class C { void M(bool a) { int x = 0; if (a && (x = g()) > 0) h(x); } }`);
const x = bindingIdx(cfg, 'x');
expect(hasMayDef(cfg, x)).toBe(true);
});
it('compound assignment reads AND writes the lvalue', () => {
const cfg = cs.cfgOf(`class C { void M() { int z = 1; z += 3; } }`);
const z = bindingIdx(cfg, 'z');
expect(hasDef(cfg, z)).toBe(true);
expect(hasUse(cfg, z)).toBe(true);
});
});
describe('C# CfgVisitor — functionStartColumn', () => {
it('two same-line methods get distinct functionStartColumn', () => {
const cfgs = cs.cfgsOf(`class C { 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 — does not throw on exotic shapes', () => {
it('lambda / anonymous method bodies build their own CFGs', () => {
const warn = vi.spyOn(console, 'warn').mockImplementation(() => {});
try {
const cfgs = cs.cfgsOf(`class C { void M() {
System.Func<int, int> f = x => { if (x > 0) { return x; } return 0; };
System.Action h = delegate () { act(); };
f(1); h();
} }`);
expect(cfgs.length).toBeGreaterThanOrEqual(3); // M, lambda, anon method
for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
} finally {
warn.mockRestore();
}
});
});