diff --git a/gitnexus/src/core/ingestion/cfg/visitors/ruby-harvest.ts b/gitnexus/src/core/ingestion/cfg/visitors/ruby-harvest.ts new file mode 100644 index 000000000..7adad3443 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/ruby-harvest.ts @@ -0,0 +1,522 @@ +/** + * Ruby def/use harvester — the Ruby analogue of + * {@link import('./python-harvest.js').PythonHarvester} (the closest structural + * sibling: implicit/keyword-delimited blocks, statement-modifier forms, a + * begin/rescue/else/ensure exception model, and `case`/`when` + `case`/`in` + * pattern matching). Like the Python harvester, this unit emits ONLY the + * per-function binding table ({@link BindingEntry}[]) plus {@link StatementFacts} + * (defs / uses / mayDefs) — NO call-site `sites[]` are harvested (the taint + * substrate is a later step), so it uses a local {@link FactAccumulator} with no + * site machinery at all and the emitted facts carry no `sites` key. + * + * Runs in the parse worker next to the Ruby CFG visitor. + * + * Every node type and field literal below was grammar-validated against + * tree-sitter-ruby via the introspection probe before use (mandatory pre-step). + * Ruby shapes pre-empted (verified by a real parse): + * - functions: `method` / `singleton_method` (fields `name`/`parameters`/`body`; + * `parameters` is a `method_parameters`; `body` is a `body_statement`), and + * blocks `do_block` (`body` = `body_statement`) / `block` (`body` = + * `block_body`) / `lambda` (`body` = a `block` wrapping a `block_body`) — each + * has a `parameters` (`block_parameters` / `lambda_parameters`). + * - parameters: bare `identifier`, `optional_parameter` (fields `name`/`value`), + * `splat_parameter` / `hash_splat_parameter` / `block_parameter` / + * `keyword_parameter` (field `name`). + * - assignment: `assignment` (fields `left`/`right`; LHS may be `identifier`, + * `left_assignment_list` (multi `a, b = …`), `instance_variable` (`@x`), + * `class_variable` (`@@x`), `global_variable` (`$x`), `constant`), + * `operator_assignment` (fields `left`/`operator`/`right` — read+write). + * - binders: `for` (fields `pattern`/`value`=`in`/`body`), block `parameters` + * (`block_parameters` of identifier / optional / splat leaves), rescue + * `variable` (an `exception_variable` wrapping the bound `identifier`). + * - reads: `call` (fields `receiver`?/`method`/`arguments`), `binary` (fields + * `left`/`operator`/`right`), `parenthesized_statements`. + * + * TWO-PHASE, ORDER-INDEPENDENT (load-bearing — mirrors the Python / TS / Go + * harvesters): the CFG walk is NOT source-order, so resolving names against a + * scope stack populated *during* the walk would mis-resolve. Phase 1 pre-scans + * the whole function subtree once, declaring every in-function local name; phase + * 2 resolves defs/uses against that finished table from any walk order. + * + * Ruby scope model (deliberately simplified, documented): Ruby binds LOCAL + * variables on first assignment; block parameters and block-local variables have + * their own block scope, but — exactly as the Python harvester declares all + * targets in a SINGLE function table (a documented over-approximation) — this + * harvester declares every assignment / for / block-param / rescue-variable / + * method-param target into one function-scope table. Instance/class/global + * variables (`@x` / `@@x` / `$x`) and bare constants are NOT local variables: a + * read or write of one is recorded as a use only (an attribute-like write — its + * "name" is not a function-scoped scalar def), matching the TS/Python member- + * write exclusion. A bare method call with no parens (`foo`) is indistinguishable + * from a local read at this layer; we resolve such an identifier against the + * local table and only mint a SYNTHETIC module binding when it is unknown (the + * conservative direction — a real method call resolves to a `module` synthetic, + * never a false local def). + * + * v1 def-semantics scope: + * - `assignment` plain `=` — each `identifier` target in the (possibly + * `left_assignment_list`) LHS is a def; an `@x`/`@@x`/`$x`/`Const` target or + * an index/attribute target is NOT a scalar def (root is a use only). + * - `operator_assignment` (`x += 1`, `x ||= y`) — def AND use the lvalue. + * - `for x in xs` / `for a, b in xs` — the loop target(s) are defs; `xs` a use. + * - block params (`|x|`, `|x, y|`, `|*rest|`) — `param`-kind defs. + * - `rescue ... => e` — `e` is a `catch`-kind def (matters to the taint pass). + * - method parameters (incl. defaults, `*splat`, `**kwsplat`, `&block`, + * keyword) — `param`-kind defs. + * + * MAY-DEFS: a def inside a conditionally-evaluated subexpression is a may-def + * (gen WITHOUT kill), so the not-taken path's prior def is not falsely killed. + * Ruby's conditional-def shapes: an assignment in the right operand of `&&`/`and` + * / `||`/`or` short-circuit (`a && (x = 1)`), and a `when`/`in` case-test + * expression / `in`-clause guard (a later case only evaluates when earlier + * patterns did not match). + * + * 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([ + 'method', + 'singleton_method', + 'do_block', + 'block', + 'lambda', +]); + +/** Parameter container node types (method + block + lambda). */ +const PARAM_CONTAINER_TYPES = new Set([ + 'method_parameters', + 'block_parameters', + 'lambda_parameters', +]); + +/** Parameter leaf node types whose `name` field (or bare identifier) is the binder. */ +const NAMED_PARAM_TYPES = new Set([ + 'optional_parameter', + 'splat_parameter', + 'hash_splat_parameter', + 'block_parameter', + 'keyword_parameter', +]); + +/** + * Minimal ordered, deduplicating def/use collector for one statement record. + * Deliberately NOT the shared {@link import('./call-site-harvest.js') + * CallSiteFactAccumulator} — this unit harvests NO call sites (taint substrate + * is a later step), so a local accumulator with only the def/use/may-def + * machinery keeps `ruby-harvest.ts` free of site logic and guarantees the + * emitted facts carry no `sites` key (matching the Python harvester). + */ +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); + } + + /** A def that may not execute (conditional context) — gen without kill. */ + 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; + } + + useCount(): number { + return this.uses.length; + } + + finish(): StatementFacts { + return { + line: this.line, + defs: this.defs, + uses: this.uses, + // Stay absent when empty — keeps the serialized side-channel payload lean. + ...(this.mayDefs.length > 0 ? { mayDefs: this.mayDefs } : {}), + }; + } +} + +export class RubyHarvester { + private readonly bindings: BindingEntry[] = []; + /** Single function-scope name → binding index (documented over-approximation). */ + private readonly table = new Map(); + private readonly synthetic = new Map(); + private readonly fnId: number; + /** >0 while walking a conditionally-evaluated subexpression — defs become may-defs. */ + private conditionalDepth = 0; + + constructor(private readonly fnNode: SyntaxNode) { + this.fnId = fnNode.id; + this.declareParams(fnNode); + const body = this.bodyOf(fnNode); + if (body) this.prescan(body); + } + + /** The completed binding table — pass to `CfgBuilder.finish`. */ + bindingTable(): readonly BindingEntry[] { + return this.bindings; + } + + /** The function/block/lambda body node. */ + private bodyOf(fnNode: SyntaxNode): SyntaxNode | undefined { + return fnNode.childForFieldName('body') ?? undefined; + } + + // ── phase 1: declaration pre-scan ──────────────────────────────────────── + + private declare(nameNode: SyntaxNode, kind: BindingEntry['kind']): void { + const name = nameNode.text; + if (!name || name === '_' || this.table.has(name)) return; + this.table.set(name, this.bindings.length); + this.bindings.push({ + name, + declLine: nameNode.startPosition.row + 1, + declColumn: nameNode.startPosition.column, + kind, + }); + } + + /** Declare every parameter binder (method / block / lambda). */ + private declareParams(fnNode: SyntaxNode): void { + const params = this.paramsOf(fnNode); + if (!params) return; + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p) this.declareParam(p); + } + } + + /** The `parameters` field (or first parameter-container child) of a function node. */ + private paramsOf(fnNode: SyntaxNode): SyntaxNode | undefined { + const field = fnNode.childForFieldName('parameters'); + if (field) return field; + return fnNode.namedChildren.find((c) => PARAM_CONTAINER_TYPES.has(c.type)); + } + + /** Declare the binder identifier of one parameter node. */ + private declareParam(p: SyntaxNode): void { + if (p.type === 'identifier') { + this.declare(p, 'param'); + return; + } + if (NAMED_PARAM_TYPES.has(p.type)) { + const name = p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier'); + if (name) this.declare(name, 'param'); + return; + } + // Destructured / grouped block param — declare any identifier leaves. + for (let i = 0; i < p.namedChildCount; i++) { + const c = p.namedChild(i); + if (c?.type === 'identifier') this.declare(c, 'param'); + else if (c) this.declareParam(c); + } + } + + /** + * Pre-scan the function body once, declaring every in-function local name. + * Recurses into compound statements but NOT into nested function/block/lambda + * bodies (opaque). + */ + private prescan(node: SyntaxNode): void { + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; + + switch (t) { + case 'assignment': { + const left = node.childForFieldName('left'); + if (left) this.declareTargets(left); + break; + } + case 'operator_assignment': { + const left = node.childForFieldName('left'); + if (left) this.declareTargets(left); + break; + } + case 'for': { + const pattern = node.childForFieldName('pattern'); + if (pattern) this.declareTargets(pattern); + break; + } + case 'rescue': { + this.declareRescueVar(node); + break; + } + default: + break; + } + + // A nested `do_block`/`block`/`lambda` body is opaque, but its OWN block + // parameters are declared (they are not local to THIS function, yet the + // single-table model harvests them where used) — handled by declareParam in + // the visitor's per-block harvester instance, not here. We only recurse to + // collect assignment/for/rescue local targets in THIS function body. + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.prescan(c); + } + } + + /** `rescue [Exc] => e` — declare `e` (a `catch`-kind def). */ + private declareRescueVar(clause: SyntaxNode): void { + const variable = clause.childForFieldName('variable'); + const id = variable?.namedChildren.find((c) => c.type === 'identifier') ?? variable; + if (id?.type === 'identifier') this.declare(id, 'catch'); + } + + /** Declare identifier leaves of an assignment / loop target (skip non-local LHS). */ + private declareTargets(target: SyntaxNode): void { + const t = target.type; + if (t === 'identifier') { + this.declare(target, 'let'); + return; + } + if (t === 'left_assignment_list') { + for (let i = 0; i < target.namedChildCount; i++) { + const c = target.namedChild(i); + if (c) this.declareTargets(c); + } + return; + } + if (t === 'splat_parameter' || t === 'rest_assignment') { + const id = target.namedChildren.find((c) => c.type === 'identifier'); + if (id) this.declare(id, 'let'); + return; + } + // instance/class/global var, constant, element/attribute target — not a + // function-scoped scalar def (the root identifier is a use only). + } + + // ── phase 2: per-statement fact extraction ─────────────────────────────── + + /** Def/use facts for one statement (or construct-header expression) node. */ + facts(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.walkValue(node, acc); + return acc.finish(); + } + + /** Facts for an expression whose WHOLE evaluation is conditional (case tests). */ + factsConditional(node: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(node.startPosition.row + 1); + this.conditional(() => this.walkValue(node, acc)); + return acc.finish(); + } + + /** + * Facts for a `for PATTERN in VALUE` head: the loop target(s) are defs, the + * iterated expression is a use. + */ + loopHeadFacts(forNode: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(forNode.startPosition.row + 1); + const pattern = forNode.childForFieldName('pattern'); + const value = forNode.childForFieldName('value'); + if (value) this.walkValue(value, acc); + if (pattern) this.defTargets(pattern, acc); + return acc.finish(); + } + + /** Facts for a `rescue [Exc] => e` header: `e` is a def, the exception list a use. */ + rescueHeadFacts(clause: SyntaxNode): StatementFacts { + const acc = new FactAccumulator(clause.startPosition.row + 1); + const exceptions = clause.childForFieldName('exceptions'); + if (exceptions) this.walkValue(exceptions, acc); + const variable = clause.childForFieldName('variable'); + const id = variable?.namedChildren.find((c) => c.type === 'identifier'); + if (id) this.def(id, acc); + return acc.finish(); + } + + /** ENTRY-block facts for the parameters (defs only — incl. default-value uses). */ + paramFacts(): StatementFacts | undefined { + const params = this.paramsOf(this.fnNode); + if (!params) return undefined; + const acc = new FactAccumulator(this.fnNode.startPosition.row + 1); + for (let i = 0; i < params.namedChildCount; i++) { + const p = params.namedChild(i); + if (p) this.defParam(p, acc); + } + return acc.defCount() || acc.useCount() ? acc.finish() : undefined; + } + + /** Def the binder of one parameter node and use any default-value expr. */ + private defParam(p: SyntaxNode, acc: FactAccumulator): void { + if (p.type === 'identifier') { + this.def(p, acc); + return; + } + if (p.type === 'optional_parameter' || p.type === 'keyword_parameter') { + const value = p.childForFieldName('value'); + if (value) this.walkValue(value, acc); + const name = p.childForFieldName('name'); + if (name) this.def(name, acc); + return; + } + if (NAMED_PARAM_TYPES.has(p.type)) { + const name = p.childForFieldName('name') ?? p.namedChildren.find((c) => c.type === 'identifier'); + if (name) this.def(name, acc); + return; + } + for (let i = 0; i < p.namedChildCount; i++) { + const c = p.namedChild(i); + if (c?.type === 'identifier') this.def(c, acc); + else if (c) this.defParam(c, acc); + } + } + + private resolve(nameNode: SyntaxNode): number { + const name = nameNode.text; + const idx = this.table.get(name); + if (idx !== undefined) return idx; + let s = this.synthetic.get(name); + if (s === undefined) { + s = this.bindings.length; + this.synthetic.set(name, s); + this.bindings.push({ name, declLine: 0, declColumn: 0, kind: 'module', synthetic: true }); + } + return s; + } + + private def(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (nameNode.text === '_') return; + if (this.conditionalDepth > 0) acc.addMayDef(this.resolve(nameNode)); + else acc.addDef(this.resolve(nameNode)); + } + + private use(nameNode: SyntaxNode, acc: FactAccumulator): void { + if (nameNode.text === '_') return; + // Resolve only known LOCAL names; an unknown bare identifier is a method + // call (resolves to a `module` synthetic — never a false local). + 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--; + } + } + + /** + * Def each identifier leaf of an assignment / loop target; route non-local + * targets (`@x`, index/attribute writes) to the value walk (root is a use). + */ + private defTargets(target: SyntaxNode, acc: FactAccumulator): void { + const t = target.type; + if (t === 'identifier') { + this.def(target, acc); + return; + } + if (t === 'left_assignment_list') { + for (let i = 0; i < target.namedChildCount; i++) { + const c = target.namedChild(i); + if (c) this.defTargets(c, acc); + } + return; + } + if (t === 'splat_parameter' || t === 'rest_assignment') { + const id = target.namedChildren.find((c) => c.type === 'identifier'); + if (id) this.def(id, acc); + else this.walkValue(target, acc); + return; + } + // instance/class/global var, constant, element/attribute target — uses only. + this.walkValue(target, acc); + } + + /** Value-position walk: collect uses; route def positions to the target handler. */ + private walkValue(node: SyntaxNode, acc: FactAccumulator): void { + const t = node.type; + if (NESTED_FUNCTION_TYPES.has(t) && node.id !== this.fnId) return; // opaque + + switch (t) { + case 'identifier': + this.use(node, acc); + return; + // Non-local variables and constants — recorded as neither a scalar def nor + // a local use (they are not function-scoped locals); nothing to add. + case 'instance_variable': + case 'class_variable': + case 'global_variable': + case 'constant': + case 'self': + return; + case 'assignment': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (right) this.walkValue(right, acc); + if (left) this.defTargets(left, acc); + return; + } + case 'operator_assignment': { + const left = node.childForFieldName('left'); + const right = node.childForFieldName('right'); + if (right) this.walkValue(right, acc); + if (left) { + if (left.type === 'identifier') { + this.use(left, acc); + this.def(left, acc); + } else { + this.walkValue(left, acc); // non-local lvalue — use only + } + } + return; + } + case 'binary': { + // `a && b` / `a || b` / `and` / `or` — the right operand is conditionally + // evaluated, so any def inside it is a may-def; uses are still recorded. + 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 === 'and' || op === 'or') { + this.conditional(() => this.walkValue(right, acc)); + } else { + this.walkValue(right, acc); + } + } + return; + } + case 'call': { + // `recv.meth(args)` / `meth(args)` — the receiver root + arguments are + // uses (the method name is not a scalar binding). A nested block child is + // its OWN function CFG (opaque here). + const receiver = node.childForFieldName('receiver'); + const args = node.childForFieldName('arguments'); + if (receiver) this.walkValue(receiver, acc); + if (args) this.walkValue(args, acc); + return; + } + default: + for (let i = 0; i < node.namedChildCount; i++) { + const c = node.namedChild(i); + if (c) this.walkValue(c, acc); + } + } + } +} diff --git a/gitnexus/src/core/ingestion/cfg/visitors/ruby.ts b/gitnexus/src/core/ingestion/cfg/visitors/ruby.ts new file mode 100644 index 000000000..3048306e0 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/ruby.ts @@ -0,0 +1,928 @@ +/** + * Ruby CfgVisitor (PDG layer beyond the C-family). Structurally closest to the + * Python visitor — keyword/indentation-free blocks delimited by `end`, + * statement-modifier forms (`expr if c`, `expr while c`), a + * begin/rescue/else/ensure exception model (ensure = finally, rescue = catch, + * the `else` runs if no exception), and `case`/`when` + `case`/`in` pattern + * matching with no fallthrough. Ruby adds a few wrinkles the other visitors do + * not have: `if`/`case`/`begin` are EXPRESSIONS, a method body is itself an + * implicit `begin` (its `body_statement` can carry trailing `rescue`/`ensure`), + * blocks (`do … end` / `{ … }`) and lambdas are their own CFG-bearing closures, + * and `until` inverts the loop sense of `while`. + * + * Walks a Ruby `method` / `singleton_method` / block / `lambda`'s tree-sitter + * AST and drives the language-agnostic {@link CfgBuilder} to produce a + * serializable {@link FunctionCfg}, plus a def/use harvest + * ({@link RubyHarvester}) for the reaching-defs / CDG solvers. Structured like + * the Python / Go visitors — a `visit_` dispatch over the statement + * taxonomy, driving a per-function {@link ControlFlowContext} for break/continue + * (next ≈ continue, redo ≈ loop-back) and the begin/ensure finalizer chain. NO + * call-site `sites[]` are harvested (taint substrate is a later step — see + * ruby-harvest.ts). + * + * Every node type and field literal below was grammar-validated against + * tree-sitter-ruby via the introspection probe before use (mandatory pre-step). + * Ruby shapes pre-empted (verified by a real parse): + * - functions: `method` / `singleton_method` (fields `name`/`parameters`/`body`; + * `body` is a `body_statement`). Blocks: `do_block` (`body` = `body_statement`) + * / `block` (`body` = `block_body`); `lambda` (`->(){}`; `body` = a `block` + * wrapping a `block_body`). The `lambda { }` keyword form is a `call` to + * `lambda` carrying a `block` — its block is collected as its own function. + * - `if` / `unless` fields `condition`/`consequence`(`then`)/`alternative`; the + * `alternative` is a nested `elsif` (own `condition`/`consequence`/`alternative`) + * or a trailing `else`. `if_modifier` / `unless_modifier` fields `body`/`condition`. + * - `while` / `until` fields `condition`/`body`(`do`); `while_modifier` / + * `until_modifier` fields `body`/`condition`. `for` fields `pattern`/`value` + * (an `in` wrapping the iterable) / `body`(`do`). + * - `case` fields `value` + `when` children (fields `pattern` (repeatable) / + * `body`(`then`)) + a trailing `else`. `case_match` (the `case … in` form) + * has fields `value` + + * `clauses`(`in_clause`, fields `pattern`/`guard`?(`if_guard`)/`body`) + an + * `else` field. No fallthrough in either. + * - `begin` directly holds its protected statements then typed `rescue` / + * `else` / `ensure` children (no field names). `rescue` fields + * `exceptions`?(`exceptions`)/`variable`?(`exception_variable`)/`body`(`then`). + * A method `body_statement` can carry the SAME trailing `rescue`/`else`/`ensure` + * children (an implicit begin). `rescue_modifier` fields `body`/`handler`. + * - `return` / `next` / `break` hold an optional `argument_list` child; + * `redo` / `retry` / `yield` are leaf-ish (`yield` may hold an `argument_list`). + * + * Edge-kind contract (matches the existing visitors — RD/CDG consume these): + * - if/unless/elsif/else → `cond-true` / `cond-false` (unless inverts the + * senses — its body is the cond-false arm) + * - while/until/for → `cond-true` / `loop-back` / `cond-false`; `until` inverts + * (its body runs while the condition is FALSE), still modeled with the same + * edge kinds (the loop body is `cond-true`, the exit `cond-false`) + * - case/when, case/in → `switch-case` (no fallthrough — like Go / Python match) + * - begin/rescue → `throw` (every protected-region block → each rescue handler); + * ensure completion → `finally-return` / `finally-break` / `finally-continue` + * - return → `return`; `break` → `break`; `next` ≈ continue → `continue`; + * `redo` → `loop-back` (re-enters the loop/block body); a jump crossing an + * `ensure` threads through it + * - straight-line → `seq` + * + * Ruby-specific modeling decisions (documented approximations): + * - a method body is an implicit `begin`: its `body_statement`'s trailing + * `rescue`/`else`/`ensure` children are modeled exactly like a `begin`. + * - `ensure` runs on BOTH the normal exit and an exception (finally semantics); + * the `rescue`-less `begin`/method re-propagates the exception after ensure. + * - `loop do … end` is `xs.loop`-shaped only when written as `loop { }`; the + * bare `loop do … end` is a `call` to `loop` carrying a `do_block`. The block + * body becomes its OWN function CFG (a closure), which — like `while true` — + * must still keep EXIT reverse-reachable. Inside the block, the structural + * exit-escape edge is emitted (the block body's normal fall-off reaches its + * EXIT). At the CALL site, the `loop … end` is a normal straight-line call. + * - `while true` (and any loop with no statically-false exit) STILL emits the + * structural `header → loopExit` `cond-false` edge so EXIT stays + * reverse-reachable and the post-dominator / CDG pass is not silently skipped. + * The single highest-risk correctness property of the visitor. + * - `retry` re-enters the nearest enclosing `begin` (its protected body); the + * edge is a `loop-back` to that begin's entry. Outside a begin it routes to + * EXIT (single-exit preserved) — a documented approximation. + * - `redo` re-runs the current loop/block body without re-testing the condition + * — modeled as a `loop-back` to the loop's continue target. + * - `if`/`case`/`begin` are EXPRESSIONS in Ruby (they have a value); as + * STATEMENTS they are modeled as control constructs. Used in expression + * position (`x = if c then 1 else 2 end`) the construct's arms are left INLINE + * inside the owning statement's block (the value flows to the assignment) — + * documented gap, mirroring the Java inline-value-switch handling. + * + * Known limitations: + * - `yield` is a normal expression here (no suspend/resume edge to the block + * passed by the caller); the generator-style resumption flow is not modeled. + * - `retry`'s re-enter target is the nearest lexical `begin`; a `retry` reached + * from a `rescue` whose begin is not on the active handler stack falls back to + * EXIT (documented approximation, not faked). + * - expression-position `case`/`begin`/`if` arms are inline (see above). + * - Def/use harvest scope: see ruby-harvest.ts — `@x`/`@@x`/`$x`/constant and + * index/attribute writes are not scalar defs; nested block / lambda bodies are + * opaque in both directions. + * + * Returns `undefined` (never throws) for an AST shape it cannot model, so a + * malformed function never drops the whole file's CFG group (R4). + */ +import type { SyntaxNode } from '../../utils/ast-helpers.js'; +import { CfgBuilder } from '../cfg-builder.js'; +import { + ControlFlowContext, + drainFinalizerPending, + wireJumpThroughFinalizers, +} from '../control-flow-context.js'; +import type { FinalizerFrame } from '../control-flow-context.js'; +import type { TraversalResult } from '../traversal-result.js'; +import type { CfgVisitor, FunctionCfg } from '../types.js'; +import { RubyHarvester } from './ruby-harvest.js'; + +/** Ruby node types that own a CFG-bearing function/closure body. */ +const RUBY_FUNCTION_TYPES = new Set([ + 'method', + 'singleton_method', + 'do_block', + 'block', + 'lambda', +]); + +/** Statement node types that break a basic block (everything else coalesces). */ +const CONTROL_FLOW_TYPES = new Set([ + 'if', + 'unless', + 'if_modifier', + 'unless_modifier', + 'while', + 'until', + 'while_modifier', + 'until_modifier', + 'for', + 'case', + 'case_match', + 'begin', + 'return', + 'break', + 'next', + 'redo', + 'retry', + 'body_statement', + 'block_body', +]); + +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 Ruby walk state. One instance per function so the + * {@link ControlFlowContext}, the exception-handler stack, and the `begin` + * re-enter stack (for `retry`) are scoped to that function and never leak. + */ +class RubyCfgWalk { + private readonly cfc = new ControlFlowContext(); + /** Stack of exception-handler entry blocks (rescue / ensure) a raise jumps to. */ + private readonly handlers: number[] = []; + /** Stack of nearest-enclosing `begin` protected-body entry blocks (for `retry`). */ + private readonly beginEntries: number[] = []; + /** + * For a block / lambda function (`do … end`, `{ … }`, `->(){}`), the body entry + * a `redo` re-enters when there is no enclosing real loop. A bare block is + * implicitly re-runnable by `redo`; set once the body's first block is known. + */ + private blockRedoTarget: number | undefined; + + /** Record the block-function body entry that a bare `redo` re-enters. */ + setBlockRedoTarget(entry: number): void { + this.blockRedoTarget = entry; + } + + constructor( + private readonly builder: CfgBuilder, + private readonly harvest: RubyHarvester, + ) {} + + /** Statements of a body node (`then`/`do`/`else`/`ensure`/`body_statement`/…), no comments. */ + private statementsOf(body: SyntaxNode): SyntaxNode[] { + return body.namedChildren.filter((c) => c.type !== 'comment'); + } + + /** Visit a body that may be a wrapper (`then`/`do`/`block_body`/`body_statement`) or a single statement. */ + private visitBody(node: SyntaxNode | undefined | null): SeqResult { + if (!node) return null; + if (this.isBodyWrapper(node)) return this.visitSeq(this.statementsOf(node)); + return this.visitStmt(node); + } + + private isBodyWrapper(node: SyntaxNode): boolean { + return ( + node.type === 'then' || + node.type === 'do' || + node.type === 'else' || + node.type === 'ensure' || + node.type === 'body_statement' || + node.type === 'block_body' + ); + } + + /** 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': + case 'unless': + return this.visitIf(stmt); + case 'if_modifier': + case 'unless_modifier': + return this.visitIfModifier(stmt); + case 'while': + case 'until': + return this.visitWhile(stmt); + case 'while_modifier': + case 'until_modifier': + return this.visitWhileModifier(stmt); + case 'for': + return this.visitFor(stmt); + case 'case': + return this.visitCase(stmt); + case 'case_match': + return this.visitCaseMatch(stmt); + case 'begin': + return this.visitBegin(stmt); + case 'return': + return this.visitReturn(stmt); + case 'break': + return this.visitBreak(stmt); + case 'next': + return this.visitNext(stmt); + case 'redo': + return this.visitRedo(stmt); + case 'retry': + return this.visitRetry(stmt); + case 'body_statement': + return this.visitImplicitBegin(stmt); + case 'block_body': + return this.visitSeq(this.statementsOf(stmt)); + default: + return this.visitSimple(stmt); + } + } + + private visitSimple(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + return { entry: idx, exits: [idx] }; + } + + /** `return [expr]` — threads through EVERY active `ensure` before EXIT. */ + private visitReturn(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + wireJumpThroughFinalizers( + this.builder, + idx, + this.cfc.finalizersForReturn(), + this.builder.exitIndex, + 'return', + ); + return { entry: idx, exits: [] }; + } + + /** `break [expr]` — exits the nearest loop/block; threads through any `ensure`. */ + private visitBreak(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + const res = this.cfc.resolveBreak(); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'break'); + return { entry: idx, exits: [] }; + } + + /** `next [expr]` ≈ continue — re-tests the nearest loop header; threads `ensure`. */ + private visitNext(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock( + startLineOf(stmt), + endLineOf(stmt), + stmt.text, + 'normal', + this.harvest.facts(stmt), + ); + const res = this.cfc.resolveContinue(); + const { target, finalizers } = res ?? { + target: this.builder.exitIndex, + finalizers: this.cfc.finalizersForReturn(), + }; + wireJumpThroughFinalizers(this.builder, idx, finalizers, target, 'continue'); + return { entry: idx, exits: [] }; + } + + /** + * `redo` — re-run the current loop/block body WITHOUT re-evaluating the + * condition. Modeled as a `loop-back` to the nearest loop's continue target + * (the loop header in the visitor's model). Outside a loop it routes to EXIT. + */ + private visitRedo(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + // A `redo` inside a real loop re-runs that loop's body (its continue target); + // a `redo` inside a bare block re-enters the block body. Falls back to EXIT + // when neither exists (single-exit preserved). + const target = this.cfc.continueTarget() ?? this.blockRedoTarget; + if (target !== undefined) this.builder.edge(idx, target, 'loop-back'); + else this.builder.edge(idx, this.builder.exitIndex, 'seq'); + return { entry: idx, exits: [] }; + } + + /** + * `retry` — re-enter the nearest enclosing `begin`'s protected body. Modeled as + * a `loop-back` to that begin's entry block. Outside any begin it routes to + * EXIT (single-exit preserved) — a documented approximation. + */ + private visitRetry(stmt: SyntaxNode): TraversalResult { + const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text); + const begin = this.beginEntries[this.beginEntries.length - 1]; + if (begin !== undefined) this.builder.edge(idx, begin, 'loop-back'); + else this.builder.edge(idx, this.builder.exitIndex, 'seq'); + return { entry: idx, exits: [] }; + } + + /** + * `if cond … elsif cond … else …` and `unless cond … else …`. Ruby has no + * nested-if else chain: an `if`/`unless` carries `condition`/`consequence`(then) + * plus an optional `alternative` that is an `elsif` (its own + * condition/consequence/alternative) or a trailing `else`. `unless` inverts the + * sense — its consequence is the cond-FALSE arm. + */ + private visitIf(stmt: SyntaxNode): TraversalResult { + const inverted = stmt.type === 'unless'; + const cond = stmt.childForFieldName('condition') ?? stmt; + const header = this.builder.newBlock( + startLineOf(stmt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + const thenKind = inverted ? 'cond-false' : 'cond-true'; + const elseKind = inverted ? 'cond-true' : 'cond-false'; + + const exits: number[] = []; + const thenRes = this.visitBody(stmt.childForFieldName('consequence')); + if (thenRes) { + this.builder.edge(header, thenRes.entry, thenKind); + exits.push(...thenRes.exits); + } else { + exits.push(header); // empty consequence — that path falls through + } + + const alt = stmt.childForFieldName('alternative'); + if (alt) { + const { exits: altExits } = this.visitIfAlternative(alt, header, elseKind); + exits.push(...altExits); + } else { + exits.push(header); // no else — the complement path falls through to the join + } + + return { entry: header, exits: [...new Set(exits)] }; + } + + /** + * Thread an `if`/`unless` alternative: an `elsif` (its own condition chained on + * the parent's false edge) or a trailing `else`. Returns the dangling exits. + */ + private visitIfAlternative( + alt: SyntaxNode, + falseFrom: number, + falseKind: 'cond-true' | 'cond-false', + ): { exits: number[] } { + const exits: number[] = []; + if (alt.type === 'elsif') { + const cond = alt.childForFieldName('condition') ?? alt; + const elifHeader = this.builder.newBlock( + startLineOf(alt), + endLineOf(cond), + cond.text, + 'normal', + this.harvest.facts(cond), + ); + this.builder.edge(falseFrom, elifHeader, falseKind); + const thenRes = this.visitBody(alt.childForFieldName('consequence')); + if (thenRes) { + this.builder.edge(elifHeader, thenRes.entry, 'cond-true'); + exits.push(...thenRes.exits); + } else { + exits.push(elifHeader); + } + const nested = alt.childForFieldName('alternative'); + if (nested) { + exits.push(...this.visitIfAlternative(nested, elifHeader, 'cond-false').exits); + } else { + exits.push(elifHeader); // elsif with no further alternative falls through + } + } else { + // a trailing `else` (consumes the false path entirely). + const elseRes = this.visitBody(alt); + if (elseRes) { + this.builder.edge(falseFrom, elseRes.entry, falseKind); + exits.push(...elseRes.exits); + } else { + exits.push(falseFrom); + } + } + return { exits }; + } + + /** + * `expr if cond` / `expr unless cond` — the modifier statement-form. The body + * runs on the taken branch; the complement falls straight through to the join. + */ + private visitIfModifier(stmt: SyntaxNode): TraversalResult { + const inverted = stmt.type === 'unless_modifier'; + const cond = stmt.childForFieldName('condition'); + const body = stmt.childForFieldName('body'); + const header = this.builder.newBlock( + startLineOf(stmt), + cond ? endLineOf(cond) : startLineOf(stmt), + cond ? cond.text : stmt.text, + 'normal', + cond ? this.harvest.facts(cond) : undefined, + ); + const takenKind = inverted ? 'cond-false' : 'cond-true'; + const exits: number[] = [header]; // the complement path falls through + + const bodyRes = body ? this.visitStmt(body) : null; + if (bodyRes) { + this.builder.edge(header, bodyRes.entry, takenKind); + exits.push(...bodyRes.exits); + } else { + this.builder.edge(header, header, takenKind === 'cond-true' ? 'cond-true' : 'cond-false'); + } + return { entry: header, exits: [...new Set(exits)] }; + } + + /** + * `while cond … end` / `until cond … end`. `until` inverts (its body runs while + * the condition is FALSE); both are modeled with `cond-true` to the body and + * `cond-false` to the loop exit (the inversion is a semantic note, not a + * structural change — the header is a single control point either way). + */ + 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(stmt.childForFieldName('body')); + 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] }; + } + + /** `expr while cond` / `expr until cond` — the modifier loop-form. */ + private visitWhileModifier(stmt: SyntaxNode): TraversalResult { + const cond = stmt.childForFieldName('condition') ?? stmt; + const body = stmt.childForFieldName('body'); + 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 bodyRes = body ? this.visitStmt(body) : null; + this.cfc.pop(); + + if (bodyRes) { + this.builder.edge(header, bodyRes.entry, 'cond-true'); + this.builder.connect(bodyRes.exits, header, 'loop-back'); + } else { + this.builder.edge(header, header, 'loop-back'); + } + this.builder.edge(header, loopExit, 'cond-false'); + return { entry: header, exits: [loopExit] }; + } + + /** + * `for PATTERN in VALUE … end`. Header = the iteration test (a def of the + * pattern target(s) + a use of the iterable). + */ + private visitFor(stmt: SyntaxNode): TraversalResult { + const value = stmt.childForFieldName('value'); + const header = this.builder.newBlock( + startLineOf(stmt), + value ? endLineOf(value) : startLineOf(stmt), + this.forHeaderText(stmt), + 'normal', + this.harvest.loopHeadFacts(stmt), + ); + const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + this.cfc.pushLoop(header, loopExit, []); + const body = this.visitBody(stmt.childForFieldName('body')); + 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 forHeaderText(stmt: SyntaxNode): string { + const pattern = stmt.childForFieldName('pattern')?.text ?? ''; + const value = stmt.childForFieldName('value')?.text ?? ''; + return pattern || value ? `for ${pattern} ${value}` : stmt.text.split('\n')[0]; + } + + /** + * `case VALUE; when P; … else; … end`. Cases do NOT fall through (like Go / + * Python match). The subject block fans a `switch-case` edge to each `when` + * body; a `case` with no `else` also reaches the join directly (no-match path), + * keeping EXIT reverse-reachable. + */ + private visitCase(stmt: SyntaxNode): TraversalResult { + const value = stmt.childForFieldName('value'); + const dispatch = this.builder.newBlock( + startLineOf(stmt), + value ? endLineOf(value) : startLineOf(stmt), + value ? `case ${value.text}` : 'case', + 'normal', + value ? this.harvest.facts(value) : undefined, + ); + const caseExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + const whenClauses = stmt.namedChildren.filter((c) => c.type === 'when'); + const elseClause = stmt.namedChildren.find((c) => c.type === 'else'); + + // Each `when` pattern evaluates conditionally before its body — harvest its + // uses onto the dispatch block (a later case only tests when earlier + // patterns did not match). + for (const w of whenClauses) { + for (const pat of this.whenPatterns(w)) { + this.builder.attachFacts(dispatch, this.harvest.factsConditional(pat)); + } + } + + this.cfc.pushSwitch(caseExit, []); + for (const w of whenClauses) { + const bodyRes = this.visitBody(w.childForFieldName('body')); + const entry = bodyRes?.entry ?? caseExit; + this.builder.edge(dispatch, entry, 'switch-case'); + if (bodyRes) this.builder.connect(bodyRes.exits, caseExit, 'seq'); + } + if (elseClause) { + const elseRes = this.visitBody(elseClause); + const entry = elseRes?.entry ?? caseExit; + this.builder.edge(dispatch, entry, 'switch-case'); + if (elseRes) this.builder.connect(elseRes.exits, caseExit, 'seq'); + } else { + // No `else` → a no-match path reaches the exit directly (keeps EXIT + // reverse-reachable even when every when body jumps). + this.builder.edge(dispatch, caseExit, 'switch-case'); + } + this.cfc.pop(); + + return { entry: dispatch, exits: [caseExit] }; + } + + /** The `pattern`-field children of a `when` clause (a `when` may list several). */ + private whenPatterns(whenClause: SyntaxNode): SyntaxNode[] { + const out: SyntaxNode[] = []; + for (let i = 0; i < whenClause.childCount; i++) { + if (whenClause.fieldNameForChild(i) === 'pattern') { + const c = whenClause.child(i); + if (c) out.push(c); + } + } + return out; + } + + /** + * `case VALUE; in PATTERN [if guard]; … else; … end` — the pattern-matching + * form (`case_match`). Same no-fallthrough dispatch as `case`/`when`; each + * `in_clause` body is dispatched with a `switch-case` edge and an `in_clause` + * guard is harvested as a conditional use on the dispatch. + */ + private visitCaseMatch(stmt: SyntaxNode): TraversalResult { + const value = stmt.childForFieldName('value'); + const dispatch = this.builder.newBlock( + startLineOf(stmt), + value ? endLineOf(value) : startLineOf(stmt), + value ? `case ${value.text}` : 'case', + 'normal', + value ? this.harvest.facts(value) : undefined, + ); + const caseExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), ''); + + const clauses = stmt.namedChildren.filter((c) => c.type === 'in_clause'); + const elseClause = stmt.childForFieldName('else') ?? stmt.namedChildren.find((c) => c.type === 'else'); + + // An `in`-clause guard (`in P if g`) evaluates conditionally on the dispatch. + for (const c of clauses) { + const guard = c.childForFieldName('guard'); + if (guard) this.builder.attachFacts(dispatch, this.harvest.factsConditional(guard)); + } + + this.cfc.pushSwitch(caseExit, []); + for (const c of clauses) { + const bodyRes = this.visitBody(c.childForFieldName('body')); + const entry = bodyRes?.entry ?? caseExit; + this.builder.edge(dispatch, entry, 'switch-case'); + if (bodyRes) this.builder.connect(bodyRes.exits, caseExit, 'seq'); + } + if (elseClause) { + const elseRes = this.visitBody(elseClause); + const entry = elseRes?.entry ?? caseExit; + this.builder.edge(dispatch, entry, 'switch-case'); + if (elseRes) this.builder.connect(elseRes.exits, caseExit, 'seq'); + } else { + // A `case … in` with no `else` raises NoMatchingPatternError on no match, + // but the structural no-match edge keeps EXIT reverse-reachable. + this.builder.edge(dispatch, caseExit, 'switch-case'); + } + this.cfc.pop(); + + return { entry: dispatch, exits: [caseExit] }; + } + + /** + * `begin … [rescue …]* [else …] [ensure …] end`. Mirrors the Python + * try-route-through: + * - `ensure` runs on every exit (normal, exception, early jumps) — a finalizer + * frame for early-exit threading + a normal/exceptional join. + * - each `rescue` handler catches from the protected body. + * - `else` runs only if the body completed with no exception. + */ + private visitBegin(stmt: SyntaxNode): SeqResult { + const children = this.statementsOf(stmt); + return this.buildProtected(stmt, children); + } + + /** + * A method `body_statement` is an implicit `begin` — its trailing + * `rescue`/`else`/`ensure` children behave exactly like a `begin`'s. When there + * are none, this is just a straight statement sequence. + */ + private visitImplicitBegin(stmt: SyntaxNode): SeqResult { + const children = this.statementsOf(stmt); + const hasHandlers = children.some( + (c) => c.type === 'rescue' || c.type === 'else' || c.type === 'ensure', + ); + if (!hasHandlers) return this.visitSeq(children); + return this.buildProtected(stmt, children); + } + + /** + * Shared begin/rescue/else/ensure builder. `children` is the construct's + * ordered named children: leading protected statements, then the typed + * `rescue` / `else` / `ensure` clauses. + */ + private buildProtected(span: SyntaxNode, children: SyntaxNode[]): SeqResult { + const protectedStmts: SyntaxNode[] = []; + const rescueClauses: SyntaxNode[] = []; + let elseClause: SyntaxNode | undefined; + let ensureClause: SyntaxNode | undefined; + for (const c of children) { + if (c.type === 'rescue') rescueClauses.push(c); + else if (c.type === 'else') elseClause = c; + else if (c.type === 'ensure') ensureClause = c; + else protectedStmts.push(c); + } + + // Build ensure first — it is both a normal join and a handler target. It runs + // OUTSIDE this begin's finalizer frame (a return inside ensure threads only + // OUTER ensures). + const ensureRes = ensureClause ? this.visitSeq(this.statementsOf(ensureClause)) : null; + const finFrame = ensureRes ? this.cfc.pushFinalizer(ensureRes.entry) : null; + + // Pre-create each rescue's facts-only HEAD block (`rescue Exc => e` binds `e` + // once, on handler entry) so the protected body knows its handler target + // BEFORE either body is walked — this lets a `retry` inside a rescue handler + // re-enter the protected body's real entry (which is walked next). + const handlerEntries = rescueClauses.map((clause) => + this.builder.newBlock( + startLineOf(clause), + startLineOf(clause), + '', + 'normal', + this.harvest.rescueHeadFacts(clause), + ), + ); + + // Handler for the protected body: the first rescue if present, else ensure, + // else the outer handler. + const tryHandler = handlerEntries[0] ?? ensureRes?.entry ?? this.currentHandler(); + const protectedStart = this.builder.blockCount; + this.handlers.push(tryHandler); + const bodyRes = this.visitSeq(protectedStmts); + this.handlers.pop(); + // Block range covering ONLY the protected body (rescue handler bodies are + // walked afterward and must NOT get the conservative per-block throw edge). + const protectedEnd = this.builder.blockCount; + + // The protected body's entry is where a `retry` re-enters (a `loop-back`). + // Push it for BOTH the rescue-handler bodies AND already-walked body (already + // done above — a `retry` in the body itself is a non-idiom, but harmless). + const beginEntry = bodyRes?.entry ?? handlerEntries[0] ?? this.builder.exitIndex; + this.beginEntries.push(beginEntry); + + // Walk each rescue handler body now (within the begin-entry scope so a + // `retry` re-enters the protected body). A raise inside a handler propagates + // to ensure (if any), else the outer handler. + const handlerExits: number[] = []; + rescueClauses.forEach((clause, i) => { + if (ensureRes) this.handlers.push(ensureRes.entry); + const headBlock = handlerEntries[i]; + const handlerBodyRes = this.visitBody(clause.childForFieldName('body')); + if (handlerBodyRes) { + this.builder.edge(headBlock, handlerBodyRes.entry, 'seq'); + handlerExits.push(...handlerBodyRes.exits); + } else { + handlerExits.push(headBlock); // empty handler — header is the exit + } + if (ensureRes) this.handlers.pop(); + }); + + this.beginEntries.pop(); + + // Conservative exceptional edges: ANY protected-region block may raise to + // EACH handler (an unmatched exception type tries the next handler). + if (rescueClauses.length > 0 || ensureClause) { + const targets = + handlerEntries.length > 0 ? handlerEntries : ensureRes ? [ensureRes.entry] : []; + for (let b = protectedStart; b < protectedEnd; b++) { + for (const h of targets) this.builder.edge(b, h, 'throw'); + } + } + + // The `else` runs only on no-exception normal completion of the body. + let normalAfterBody: number[] = bodyRes ? [...bodyRes.exits] : []; + if (elseClause) { + const elseRes = this.visitBody(elseClause); + if (elseRes && bodyRes) { + this.builder.connect(bodyRes.exits, elseRes.entry, 'seq'); + normalAfterBody = [...elseRes.exits]; + } else if (elseRes) { + normalAfterBody = [...elseRes.exits]; + } + } + + // Close the finalizer frame; wire crossing-jump completion legs. + if (finFrame && ensureRes) { + this.cfc.pop(); + drainFinalizerPending(this.builder, finFrame, ensureRes.exits); + } + + const exits: number[] = []; + if (ensureRes) { + // Normal completion of (body→else) AND each handler flows through ensure. + this.builder.connect(normalAfterBody, ensureRes.entry, 'seq'); + this.builder.connect(handlerExits, ensureRes.entry, 'seq'); + exits.push(...ensureRes.exits); + // A begin with no rescue → an uncaught exception re-propagates after ensure. + if (handlerEntries.length === 0) { + this.builder.connect(ensureRes.exits, this.currentHandler(), 'throw'); + } + } else { + exits.push(...normalAfterBody); + exits.push(...handlerExits); + } + + const entry = bodyRes?.entry ?? ensureRes?.entry ?? handlerEntries[0]; + if (entry === undefined) { + void span; + return null; + } + return { entry, exits: [...new Set(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 body node of a Ruby function/block/lambda (a `block` for `lambda` unwraps once). */ +function functionBody(fnNode: SyntaxNode): SyntaxNode | undefined { + const body = fnNode.childForFieldName('body'); + if (!body) return undefined; + // A `lambda`'s `body` is a `block` wrapping a `block_body` — unwrap to the + // inner body so the walk sees the statement sequence. + if (fnNode.type === 'lambda' && body.type === 'block') { + return body.childForFieldName('body') ?? body; + } + return body; +} + +/** Build the CFG for one Ruby function/block/lambda node, or `undefined` if not modelable. */ +function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined { + try { + if (!RUBY_FUNCTION_TYPES.has(fnNode.type)) return undefined; + const startLine = startLineOf(fnNode); + const endLine = endLineOf(fnNode); + const startColumn = fnNode.startPosition.column; + + const body = functionBody(fnNode); + if (!body) { + // No body — an empty `def f; end` still gets a trivial ENTRY → EXIT CFG so + // it is not silently dropped. + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new RubyHarvester(fnNode); + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); + return builder.finish(harvest.bindingTable()); + } + + const builder = new CfgBuilder(filePath, startLine, endLine, startColumn); + const harvest = new RubyHarvester(fnNode); + + const paramFacts = harvest.paramFacts(); + if (paramFacts) builder.attachFacts(builder.entryIndex, paramFacts); + + const walk = new RubyCfgWalk(builder, harvest); + // For a bare block / lambda, a `redo` re-enters the block body. The body's + // first block is the NEXT index the builder will allocate (entry + exit are + // already taken). Set it so a top-level `redo` inside the block loops back. + if (fnNode.type !== 'method' && fnNode.type !== 'singleton_method') { + walk.setBlockRedoTarget(builder.blockCount); + } + const res = walk.visitStmt(body); + if (!res) { + builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body + return builder.finish(harvest.bindingTable()); + } + builder.edge(builder.entryIndex, res.entry, 'seq'); + builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT + return builder.finish(harvest.bindingTable()); + } 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] Ruby buildFunctionCfg skipped a function in ${filePath}: ${String(err)}`); + return undefined; + } +} + +/** Whether a node is a Ruby function/closure this visitor builds a CFG for. */ +function isFunction(node: SyntaxNode): boolean { + return RUBY_FUNCTION_TYPES.has(node.type); +} + +/** The Ruby CFG visitor. */ +export function createRubyCfgVisitor(): CfgVisitor { + return { buildFunctionCfg, isFunction }; +} + +export { RUBY_FUNCTION_TYPES }; diff --git a/gitnexus/src/core/ingestion/languages/ruby.ts b/gitnexus/src/core/ingestion/languages/ruby.ts index a5101bfcf..d8f340472 100644 --- a/gitnexus/src/core/ingestion/languages/ruby.ts +++ b/gitnexus/src/core/ingestion/languages/ruby.ts @@ -28,6 +28,7 @@ import { createVariableExtractor } from '../variable-extractors/generic.js'; import { rubyVariableConfig } from '../variable-extractors/configs/ruby.js'; import { createCallExtractor } from '../call-extractors/generic.js'; import { rubyCallConfig } from '../call-extractors/configs/ruby.js'; +import { createRubyCfgVisitor } from '../cfg/visitors/ruby.js'; import { emitRubyScopeCaptures, rubyArityCompatibility, @@ -205,6 +206,7 @@ export const rubyProvider = defineLanguage({ builtInNames: BUILT_INS, // ── RFC #909 Ring 3: scope-based resolution hooks ────────── emitScopeCaptures: emitRubyScopeCaptures, + cfgVisitor: createRubyCfgVisitor(), interpretImport: interpretRubyImport, interpretTypeBinding: interpretRubyTypeBinding, bindingScopeFor: rubyBindingScopeFor, diff --git a/gitnexus/test/integration/cfg/fixtures/ruby-hazards.rb b/gitnexus/test/integration/cfg/fixtures/ruby-hazards.rb new file mode 100644 index 000000000..ffb2109af --- /dev/null +++ b/gitnexus/test/integration/cfg/fixtures/ruby-hazards.rb @@ -0,0 +1,172 @@ +# Ruby CFG hazard fixture. Exercises every control-flow construct the Ruby +# visitor models, including the EXIT-reachability hazard (`while true` / `loop do` +# with no static exit) the CDG soundness gate depends on, plus the Ruby-specific +# shapes: keyword/`end`-delimited blocks, elsif/unless, the statement-modifier +# forms (`x if c`, `x while c`), until (inverted), case/when (no fallthrough), +# case/in pattern matching, begin/rescue/else/ensure (+ method-level implicit +# begin), retry, blocks/lambdas as closures, next/break/redo, and the def shapes +# (multiple assignment, operator-assign, block params, rescue `=> e`). + +def if_elif_else(x) + # if / elsif / else branch senses. + if x > 0 + r = positive() + elsif x < 0 + r = negative() + else + r = zero() + end + r +end + +def unless_form(x) + # unless inverts the sense — the body is the cond-false arm. + guard() unless x + done() +end + +def modifier_forms(c) + # statement-modifier if / while. + y = compute() if c + step() while c + y +end + +def while_loop(x) + # while: header + body + loop-back. + while x > 0 + x -= 1 + end + x +end + +def until_loop + # until inverts: the body runs while the condition is FALSE. + until done? + advance() + end +end + +def for_loop(xs) + # for: the loop var is a def, the iterable a use. + total = 0 + for i in xs + total += i + end + total +end + +def infinite_loop(x) + # `while true` with no static exit — the EXIT-reachability / CDG hazard. The + # structural cond-false escape edge keeps the post-dominator pass running. + while true + handle() if x + end +end + +def loop_block + # `loop do … end` — the block is its own closure CFG; its body's normal + # fall-off must keep the block EXIT reverse-reachable. + loop do + work() + end +end + +def case_when(x) + # case/when — no fallthrough between case bodies. + case x + when 1 + one() + when 2, 3 + two_or_three() + else + other() + end +end + +def case_in(obj) + # case/in pattern matching — no fallthrough; the patterns bind. + case obj + in [a, b] + pair(a, b) + in { id: } if id > 0 + by_id(id) + in Integer + int() + else + no_match() + end +end + +def begin_rescue_else_ensure + # begin/rescue/else/ensure — ensure runs on every path; the else only on the + # no-exception path; the protected body throws to the handler. + begin + risky() + rescue StandardError => e + handle(e) + rescue TypeError + handle_type() + else + no_error() + ensure + cleanup() + end + after() +end + +def method_implicit_begin + # a method body is an implicit begin — trailing rescue/ensure thread correctly. + perform() +rescue => err + recover(err) +ensure + finalize() +end + +def retry_loop + # retry re-enters the begin protected body. + attempts = 0 + begin + attempts += 1 + connect() + rescue + retry if attempts < 3 + end +end + +def return_through_ensure(x) + # a return inside begin threads through ensure (finally-return). + begin + return early() if x + body() + ensure + release() + end +end + +def block_jumps(xs) + # next ≈ continue, break exits, redo re-runs the block body. + xs.each do |n| + next if n.skip? + break if n.stop? + redo if n.retry? + use(n) + end +end + +def def_shapes(a, b = 1, *rest, key:, **opts, &blk) + # def shapes: defaults, splat, kwsplat, keyword, block param; multiple assign; + # operator-assign; instance/class/global vars (NOT scalar local defs). + first, second = a, b + total = first + total += second + @cache = total + @@registry = rest + $global = opts + blk.call(key) if blk + total +end + +square = ->(q) { q * q } +doubler = lambda { |r| r + r } diff --git a/gitnexus/test/unit/cfg/ruby-visitor.test.ts b/gitnexus/test/unit/cfg/ruby-visitor.test.ts new file mode 100644 index 000000000..217f19da8 --- /dev/null +++ b/gitnexus/test/unit/cfg/ruby-visitor.test.ts @@ -0,0 +1,408 @@ +import { describe, it, expect } from 'vitest'; +import { createRequire } from 'node:module'; +import { createRubyCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/ruby.js'; +import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js'; +import { makeCfgHarness, type CfgHarness } from '../../helpers/cfg-harness.js'; +import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js'; +import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js'; + +// The Ruby CfgVisitor — one hazard per test (real-parser regression, NOT +// snapshot-pinning). Each fixture's distinctive statement text (one(), done(), +// after(), …) lets us locate the block for a region by text and assert the +// control-flow topology around it. Ruby is structurally close to Python: +// keyword/`end`-delimited blocks, statement-modifier forms, begin/rescue/else/ +// ensure, case/when + case/in. The `while true` EXIT-reachability + CDG +// regressions are load-bearing. + +const rubyGrammar = createRequire(import.meta.url)('tree-sitter-ruby') as Parameters< + typeof makeCfgHarness +>[0]; + +const rb: CfgHarness = makeCfgHarness(rubyGrammar, createRubyCfgVisitor(), 'fixture.rb'); + +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); + +/** 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))); + +/** An edge {from, to, kind} exists. */ +const hasEdge = (cfg: FunctionCfg, from: number, to: number, kind: string): boolean => + cfg.edges.some((e) => e.from === from && e.to === to && e.kind === kind); + +describe('Ruby CfgVisitor — structure', () => { + it('straight-line body: ENTRY → block → EXIT (seq)', () => { + const cfg = rb.cfgOf(`def f\n a()\n b()\n c()\nend\n`); + expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1); + const body = block(cfg, 'a()'); + expect(hasEdge(cfg, cfg.entryIndex, body, 'seq')).toBe(true); + expect(reaches(cfg, body, cfg.exitIndex)).toBe(true); + }); + + it('empty method body: ENTRY → EXIT, never throws', () => { + const cfg = rb.cfgOf(`def f\nend\n`); + expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('method, singleton_method, block and lambda are all CFG-bearing', () => { + const cfgs = rb.cfgsOf( + `def f\n x()\nend\n\ndef self.g\n y()\nend\n\n[1].each { |n| use(n) }\n\nh = ->(q) { q + 1 }\n`, + ); + expect(cfgs.length).toBeGreaterThanOrEqual(4); + for (const cfg of cfgs) expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true); + }); + + it('unmodeled / no-method shape → never throws', () => { + const root = rb.parse(`class C\n X = 1\nend\n`); + const fns = rb.collectFunctions(root); + for (const fn of fns) { + expect(() => createRubyCfgVisitor().buildFunctionCfg(fn, 'f.rb')).not.toThrow(); + } + }); +}); + +describe('Ruby CfgVisitor — if / elsif / else / unless', () => { + it('if/elsif/else: branch senses; every arm reaches the join', () => { + const cfg = rb.cfgOf( + `def f(x)\n if x > 0\n a()\n elsif x < 0\n b()\n else\n c()\n end\n after()\nend\n`, + ); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const join = block(cfg, 'after()'); + expect(reaches(cfg, block(cfg, 'a()'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'b()'), join)).toBe(true); + expect(reaches(cfg, block(cfg, 'c()'), join)).toBe(true); + const ifHeader = block(cfg, 'x > 0'); + const elifHeader = block(cfg, 'x < 0'); + expect(hasEdge(cfg, ifHeader, block(cfg, 'a()'), 'cond-true')).toBe(true); + expect(hasEdge(cfg, ifHeader, elifHeader, 'cond-false')).toBe(true); + expect(hasEdge(cfg, elifHeader, block(cfg, 'b()'), 'cond-true')).toBe(true); + expect(hasEdge(cfg, elifHeader, block(cfg, 'c()'), 'cond-false')).toBe(true); + }); + + it('unless inverts the branch sense (body is the cond-false arm)', () => { + const cfg = rb.cfgOf(`def f(x)\n unless x\n u()\n end\n after()\nend\n`); + const header = cfg.blocks.find((b) => b.text === 'x')!.index; + // unless's body runs when the condition is FALSE. + expect(hasEdge(cfg, header, block(cfg, 'u()'), 'cond-false')).toBe(true); + expect(reaches(cfg, header, block(cfg, 'after()'))).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + }); + + it('if with no else: cond-true to the body, fall-through to the join', () => { + const cfg = rb.cfgOf(`def f(x)\n if x\n a()\n end\n after()\nend\n`); + const header = cfg.blocks.find((b) => b.text === 'x')!.index; + const after = block(cfg, 'after()'); + expect(hasEdge(cfg, header, block(cfg, 'a()'), 'cond-true')).toBe(true); + expect(reaches(cfg, header, after)).toBe(true); + expect(reaches(cfg, block(cfg, 'a()'), after)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + }); +}); + +describe('Ruby CfgVisitor — statement-modifier forms', () => { + it('`x = 1 if c` runs the body on cond-true; complement falls through', () => { + const cfg = rb.cfgOf(`def f(c)\n y = 1 if c\n after()\nend\n`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + const header = cfg.blocks.find((b) => b.text === 'c')!.index; + const body = block(cfg, 'y = 1'); + expect(hasEdge(cfg, header, body, 'cond-true')).toBe(true); + // both the taken body and the complement reach the join. + expect(reaches(cfg, header, block(cfg, 'after()'))).toBe(true); + expect(reaches(cfg, body, block(cfg, 'after()'))).toBe(true); + }); + + it('`step() while c` is a modifier loop: cond-true / loop-back / cond-false', () => { + const cfg = rb.cfgOf(`def f(c)\n step() while c\n after()\nend\n`); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); + const header = cfg.blocks.find((b) => b.text === 'c')!.index; + expect(reaches(cfg, block(cfg, 'step()'), header)).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — while / until / for / loop', () => { + it('while: header + body + loop-back; exit reachable', () => { + const cfg = rb.cfgOf(`def f(x)\n while x > 0\n x -= 1\n end\n done()\nend\n`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + const body = block(cfg, 'x -= 1'); + 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); + }); + + it('until inverts: body runs while the condition is false, exit on cond-false', () => { + const cfg = rb.cfgOf(`def f\n until done\n step()\n end\n after()\nend\n`); + const kinds = edgeKinds(cfg); + expect(kinds.has('cond-true')).toBe(true); // header → body + expect(kinds.has('loop-back')).toBe(true); + expect(kinds.has('cond-false')).toBe(true); // header → loop exit + const body = block(cfg, 'step()'); + const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to; + expect(header).toBeDefined(); + expect(reaches(cfg, header!, block(cfg, 'after()'))).toBe(true); + }); + + it('for x in xs: loop var is a def, iterable a use; loop-back + exit', () => { + const cfg = rb.cfgOf(`def f(xs)\n for i in xs\n use(i)\n end\n done()\nend\n`); + expect(edgeKinds(cfg).has('cond-true')).toBe(true); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + expect(hasDef(cfg, bindingIdx(cfg, 'i'))).toBe(true); + expect(hasUse(cfg, bindingIdx(cfg, 'xs'))).toBe(true); + const body = block(cfg, 'use(i)'); + const header = cfg.edges.find((e) => e.kind === 'loop-back' && e.from === body)?.to; + expect(reaches(cfg, header!, block(cfg, 'done()'))).toBe(true); + }); + + it('`while true` keeps EXIT reverse-reachable AND emits CDG > 0', () => { + const cfg = rb.cfgOf(`def f(x)\n while true\n g() if x\n end\nend\n`); + expect(edgeKinds(cfg).has('cond-false')).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + }); + + it('`loop do … end` block keeps its own EXIT reverse-reachable', () => { + // `loop do … end` parses as a call carrying a do_block — the block is its own + // CFG. The block body's normal fall-off reaches the block EXIT (structural + // escape), so the post-dominator pass is not silently skipped for it. + const cfgs = rb.cfgsOf(`def f\n loop do\n work()\n end\nend\n`); + expect(cfgs.length).toBeGreaterThanOrEqual(2); + for (const cfg of cfgs) expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — case / when (no fallthrough)', () => { + it('case/when dispatches across cases; no fallthrough between case bodies', () => { + const cfg = rb.cfgOf( + `def f(x)\n case x\n when 1\n one()\n when 2\n two()\n else\n other()\n end\n after()\nend\n`, + ); + 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); + expect(reaches(cfg, block(cfg, 'other()'), block(cfg, 'after()'))).toBe(true); + // case 1 does NOT fall into case 2 (no implicit fallthrough). + expect(reaches(cfg, block(cfg, 'one()'), block(cfg, 'two()'))).toBe(false); + }); + + it('case/when with no else keeps EXIT reverse-reachable (no-match path)', () => { + const cfg = rb.cfgOf( + `def f(x)\n case x\n when 1\n one()\n when 2\n two()\n end\n after()\nend\n`, + ); + const dispatch = cfg.blocks.find((b) => b.text.startsWith('case '))!.index; + expect(reaches(cfg, dispatch, block(cfg, 'after()'))).toBe(true); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — case / in (pattern matching, no fallthrough)', () => { + it('case/in dispatches; the array-pattern binds, no fallthrough', () => { + const cfg = rb.cfgOf( + `def f(obj)\n case obj\n in [a, b]\n pair(a, b)\n in Integer\n int()\n else\n no()\n end\nend\n`, + ); + expect(edgeKinds(cfg).has('switch-case')).toBe(true); + expect(reaches(cfg, block(cfg, 'pair(a, b)'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'int()'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'no()'), cfg.exitIndex)).toBe(true); + expect(reaches(cfg, block(cfg, 'pair(a, b)'), block(cfg, 'int()'))).toBe(false); + }); + + it('an in-clause guard is harvested as a conditional use on the dispatch', () => { + const cfg = rb.cfgOf( + `def f(x, k)\n case x\n in Integer if x > k\n use(x)\n else\n other()\n end\nend\n`, + ); + expect(hasUse(cfg, bindingIdx(cfg, 'k'))).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — begin / rescue / else / ensure', () => { + it('begin/rescue/else/ensure: completion edges; ensure runs on all paths', () => { + const cfg = rb.cfgOf( + `def f\n begin\n body()\n rescue StandardError => e\n handle(e)\n else\n noerr()\n ensure\n cleanup()\n end\n after()\nend\n`, + ); + const ensureB = block(cfg, 'cleanup()'); + expect(reaches(cfg, block(cfg, 'body()'), ensureB)).toBe(true); + expect(reaches(cfg, block(cfg, 'handle(e)'), ensureB)).toBe(true); + expect(reaches(cfg, block(cfg, 'noerr()'), ensureB)).toBe(true); + expect(reaches(cfg, ensureB, block(cfg, 'after()'))).toBe(true); + // the protected body can throw to the handler. + expect(edgeKinds(cfg).has('throw')).toBe(true); + expect(reaches(cfg, block(cfg, 'body()'), block(cfg, 'handle(e)'))).toBe(true); + // the else runs only after the body (no exception). + expect(reaches(cfg, block(cfg, 'body()'), block(cfg, 'noerr()'))).toBe(true); + // `rescue ... => e` binds e (catch-kind). + expect(hasDef(cfg, bindingIdx(cfg, 'e'))).toBe(true); + }); + + it('method-level implicit begin: a bare def + rescue/ensure threads correctly', () => { + const cfg = rb.cfgOf( + `def f\n risky()\nrescue => e\n handle()\nensure\n done()\nend\n`, + ); + const ensureB = block(cfg, 'done()'); + expect(reaches(cfg, block(cfg, 'risky()'), block(cfg, 'handle()'))).toBe(true); + expect(reaches(cfg, block(cfg, 'handle()'), ensureB)).toBe(true); + expect(reaches(cfg, ensureB, cfg.exitIndex)).toBe(true); + expect(edgeKinds(cfg).has('throw')).toBe(true); + }); + + it('multiple rescue clauses are both reachable from the protected body', () => { + const cfg = rb.cfgOf( + `def f\n begin\n body()\n rescue TypeError\n handleT()\n rescue KeyError\n handleK()\n end\nend\n`, + ); + expect(reaches(cfg, block(cfg, 'body()'), block(cfg, 'handleT()'))).toBe(true); + expect(reaches(cfg, block(cfg, 'body()'), block(cfg, 'handleK()'))).toBe(true); + }); + + it('return inside begin threads through ensure (finally-return)', () => { + const cfg = rb.cfgOf( + `def f(x)\n begin\n return 1 if x\n body()\n ensure\n cleanup()\n end\nend\n`, + ); + const ensureB = block(cfg, 'cleanup()'); + expect(reaches(cfg, block(cfg, 'return 1'), ensureB)).toBe(true); + expect(edgeKinds(cfg).has('finally-return')).toBe(true); + expect(reaches(cfg, ensureB, cfg.exitIndex)).toBe(true); + }); + + it('retry re-enters the begin protected body (loop-back)', () => { + const cfg = rb.cfgOf( + `def f\n begin\n risky()\n rescue\n retry\n end\nend\n`, + ); + expect(edgeKinds(cfg).has('loop-back')).toBe(true); + const retryB = block(cfg, 'retry'); + const bodyB = block(cfg, 'risky()'); + expect(hasEdge(cfg, retryB, bodyB, 'loop-back')).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — block jumps (next / break / redo)', () => { + it('next ≈ continue, break exits, redo re-enters the block body', () => { + // The block (`do … end`) is its own CFG; inspect it (index 1, after the method). + const cfgs = rb.cfgsOf( + `def f(xs)\n xs.each do |n|\n next if n == 1\n break if n == 2\n redo if n == 3\n use(n)\n end\nend\n`, + ); + const blk = cfgs[1]; + const kinds = new Set(blk.edges.map((e) => e.kind)); + expect(kinds.has('continue')).toBe(true); // next + expect(kinds.has('break')).toBe(true); // break + expect(kinds.has('loop-back')).toBe(true); // redo re-enters the body + // block param |n| is a def. + expect(hasDef(blk, bindingIdx(blk, 'n'))).toBe(true); + // redo loops back to the block body entry, NOT to EXIT. + const redoB = blk.blocks.find((b) => b.text === 'redo')!.index; + expect(blk.edges.some((e) => e.from === redoB && e.kind === 'loop-back')).toBe(true); + }); +}); + +describe('Ruby CfgVisitor — def/use harvest', () => { + it('x = 1 then use(x): def then use', () => { + const cfg = rb.cfgOf(`def f\n x = 1\n use(x)\nend\n`); + const x = bindingIdx(cfg, 'x'); + expect(hasDef(cfg, x)).toBe(true); + expect(hasUse(cfg, x)).toBe(true); + }); + + it('multiple assignment a, b = f() defines BOTH a and b', () => { + const cfg = rb.cfgOf(`def f\n a, b = load()\n use(a, b)\nend\n`); + expect(hasDef(cfg, bindingIdx(cfg, 'a'))).toBe(true); + expect(hasDef(cfg, bindingIdx(cfg, 'b'))).toBe(true); + expect(hasUse(cfg, bindingIdx(cfg, 'a'))).toBe(true); + expect(hasUse(cfg, bindingIdx(cfg, 'b'))).toBe(true); + }); + + it('operator-assign x += 1 reads AND writes the lvalue', () => { + const cfg = rb.cfgOf(`def f\n x = 1\n x += 3\nend\n`); + const x = bindingIdx(cfg, 'x'); + expect(hasDef(cfg, x)).toBe(true); + expect(hasUse(cfg, x)).toBe(true); + }); + + it('block param |x| defines x', () => { + const cfgs = rb.cfgsOf(`def f(xs)\n xs.map { |item| item * 2 }\nend\n`); + const blk = cfgs[1]; + expect(hasDef(blk, bindingIdx(blk, 'item'))).toBe(true); + expect(hasUse(blk, bindingIdx(blk, 'item'))).toBe(true); + }); + + it('method params (incl. defaults / *splat / **kwsplat / &block / keyword) define at ENTRY', () => { + const cfg = rb.cfgOf( + `def f(a, b = 1, *rest, key:, **opts, &blk)\n use(a, b, rest, key, opts)\nend\n`, + ); + for (const name of ['a', 'b', 'rest', 'key', 'opts', 'blk']) { + expect(hasDef(cfg, bindingIdx(cfg, name))).toBe(true); + } + }); + + it('an assignment in an && right operand is a may-def (conditional context)', () => { + const cfg = rb.cfgOf(`def f(a, b)\n z = a && (w = b)\n use(z)\nend\n`); + expect(hasMayDef(cfg, bindingIdx(cfg, 'w'))).toBe(true); + // not a must-def — the not-taken short-circuit path skips it. + expect(hasDef(cfg, bindingIdx(cfg, 'w'))).toBe(false); + }); + + it('instance/class/global var writes are NOT scalar local defs', () => { + const cfg = rb.cfgOf(`def f\n @ivar = 1\n @@cvar = 2\n $g = 3\nend\n`); + for (const name of ['@ivar', '@@cvar', '$g']) { + expect((cfg.bindings ?? []).some((b) => b.name === name)).toBe(false); + } + }); +}); + +describe('Ruby CfgVisitor — functionStartColumn', () => { + it('two same-line blocks get distinct functionStartColumn', () => { + const cfgs = rb.cfgsOf(`a.map { |x| x() }; b.map { |y| y() }\n`); + const blocks = cfgs.filter((c) => c.functionStartLine === 1); + expect(blocks.length).toBeGreaterThanOrEqual(2); + expect(blocks[0].functionStartColumn).not.toBe(blocks[1].functionStartColumn); + }); +}); + +describe('Ruby CfgVisitor — production CDG probe (plan-required)', () => { + it('while true / nested-if gives exitReachable=true and CDG edges > 0', () => { + const cfg = rb.cfgOf(`def f(x)\n while true\n g() if x\n end\nend\n`); + expect(isExitReachableFromAllBlocks(cfg)).toBe(true); + expect(computeControlDependence(cfg).edges.length).toBeGreaterThan(0); + }); +}); + +describe('Ruby CfgVisitor — no taint sites harvested (this unit)', () => { + it('statements carry NO sites key (taint substrate is a later step)', () => { + const cfg = rb.cfgOf(`def f(cmd)\n exec(cmd)\n x = escape(cmd)\n use(x)\nend\n`); + const anySites = cfg.blocks.some((b) => + (b.statements ?? []).some((s) => (s as { sites?: unknown }).sites !== undefined), + ); + expect(anySites).toBe(false); + }); +});