From aad3a702b91c0e4fedf46d814b52071ae2bab6a0 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 11:10:25 +0000 Subject: [PATCH] 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) --- .../ingestion/cfg/visitors/csharp-harvest.ts | 543 +++++++++++ .../src/core/ingestion/cfg/visitors/csharp.ts | 909 ++++++++++++++++++ .../src/core/ingestion/languages/csharp.ts | 2 + .../cfg/fixtures/csharp-hazards.cs | 159 +++ gitnexus/test/unit/cfg/csharp-visitor.test.ts | 389 ++++++++ 5 files changed, 2002 insertions(+) create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts create mode 100644 gitnexus/src/core/ingestion/cfg/visitors/csharp.ts create mode 100644 gitnexus/test/integration/cfg/fixtures/csharp-hazards.cs create mode 100644 gitnexus/test/unit/cfg/csharp-visitor.test.ts diff --git a/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts new file mode 100644 index 000000000..18016a7d1 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/csharp-harvest.ts @@ -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; +} + +export class CsharpHarvester { + private readonly bindings: BindingEntry[] = []; + private readonly scopeByNode = new Map(); + private readonly root: Scope = { parent: null, table: new Map() }; + private readonly synthetic = new Map(); + private readonly fnId: number; + /** Innermost enclosing scope per visited node id (prescan-filled) — O(scope-chain) phase-2 resolution. */ + private readonly nearestScopeCache = new Map(); + /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */ + private conditionalDepth = 0; + + constructor(private readonly fnNode: SyntaxNode) { + this.fnId = fnNode.id; + this.scopeByNode.set(fnNode.id, this.root); + this.declareParams(fnNode); + const body = this.bodyOf(fnNode); + if (body) this.prescan(body, this.openScope(body)); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + table(): readonly BindingEntry[] { + return this.bindings; + } + + /** The function/lambda body 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(); + private readonly useSeen = new Set(); + private readonly mayDefSeen = new Set(); + + constructor(private readonly line: number) {} + + addDef(idx: number): void { + if (this.defSeen.has(idx)) return; + this.defSeen.add(idx); + this.defs.push(idx); + } + + addMayDef(idx: number): void { + if (this.mayDefSeen.has(idx)) return; + this.mayDefSeen.add(idx); + this.mayDefs.push(idx); + } + + addUse(idx: number): void { + if (this.useSeen.has(idx)) return; + this.useSeen.add(idx); + this.uses.push(idx); + } + + defCount(): number { + return this.defs.length + this.mayDefs.length; + } + + finish(): StatementFacts { + return { + line: this.line, + defs: this.defs, + uses: this.uses, + ...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}), + }; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts b/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts new file mode 100644 index 000000000..48a58ad78 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/csharp.ts @@ -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_` 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(); + /** Pending gotos to a label not yet seen: label → list of source blocks. */ + private readonly pendingGotos = new Map(); + + 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 | 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 { + return { buildFunctionCfg, isFunction }; +} + +export { CSHARP_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/csharp.ts b/gitnexus/src/core/ingestion/languages/csharp.ts index 870c6c862..612e33fdb 100644 --- a/gitnexus/src/core/ingestion/languages/csharp.ts +++ b/gitnexus/src/core/ingestion/languages/csharp.ts @@ -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, diff --git a/gitnexus/test/integration/cfg/fixtures/csharp-hazards.cs b/gitnexus/test/integration/cfg/fixtures/csharp-hazards.cs new file mode 100644 index 000000000..55253c258 --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/csharp-hazards.cs @@ -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 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; + } +} diff --git a/gitnexus/test/unit/cfg/csharp-visitor.test.ts b/gitnexus/test/unit/cfg/csharp-visitor.test.ts new file mode 100644 index 000000000..9a952f2dd --- /dev/null +++ b/gitnexus/test/unit/cfg/csharp-visitor.test.ts @@ -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[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 => new Set(cfg.edges.map((e) => e.kind)); + +function reaches(cfg: FunctionCfg, from: number, to: number): boolean { + const adj = new Map(); + for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to); + const seen = new Set([from]); + const stack = [from]; + while (stack.length) { + const n = stack.pop() as number; + if (n === to) return true; + for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx)); + } + return seen.has(to); +} +const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx); + +/** Is EXIT reverse-reachable from every reachable block? (CDG soundness gate.) */ +function exitReachableFromAll(cfg: FunctionCfg): boolean { + for (const b of cfg.blocks) { + if (b.index === cfg.exitIndex) continue; + if (!reachable(cfg, b.index)) continue; // unreachable blocks exempt + if (!reaches(cfg, b.index, cfg.exitIndex)) return false; + } + return true; +} + +/** Resolve a binding by name → its index in the function's binding table. */ +function bindingIdx(cfg: FunctionCfg, name: string): number { + const i = (cfg.bindings ?? []).findIndex((b) => b.name === name); + if (i < 0) throw new Error(`no binding ${name}`); + return i; +} + +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 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 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 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(); + } + }); +});