feat(cfg): Ruby CFG visitor + def/use harvest (#2195 U10)

Add createRubyCfgVisitor + ruby-harvest: if/unless/elsif/else +
statement-modifier forms (x if c, x while c), while/until/for (until
inverts the sense), case/when + case/in (pattern, no fallthrough),
begin/rescue/else/ensure (ensure=finally, rescue=catch) + retry
(loop-back into begin), return/break/next/redo, blocks/lambdas as their
own closure CFGs. Wire into rubyProvider.

Every literal validated against tree-sitter-ruby via the probe (case vs
case_match, modifier nodes, typed rescue/ensure children). loop do /
while true keep EXIT reverse-reachable (production CDG probe: 3 edges).
34 real-parser tests; comprehensive sweep green (486). Gaps: yield,
expression-position if/case/begin inline, ivar/gvar non-local defs.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 13:02:17 +00:00
parent 5e72cfe57a
commit ad0a50271e
5 changed files with 2032 additions and 0 deletions

View file

@ -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<number>();
private readonly useSeen = new Set<number>();
private readonly mayDefSeen = new Set<number>();
constructor(private readonly line: number) {}
addDef(idx: number): void {
if (this.defSeen.has(idx)) return;
this.defSeen.add(idx);
this.defs.push(idx);
}
/** 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<string, number>();
private readonly synthetic = new Map<string, number>();
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);
}
}
}
}

View file

@ -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_<node_type>` 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<SyntaxNode> {
return { buildFunctionCfg, isFunction };
}
export { RUBY_FUNCTION_TYPES };

View file

@ -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,

View file

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

View file

@ -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<string> => new Set(cfg.edges.map((e) => e.kind));
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
const seen = new Set([from]);
const stack = [from];
while (stack.length) {
const n = stack.pop() as number;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
return seen.has(to);
}
const reachable = (cfg: FunctionCfg, idx: number): boolean => reaches(cfg, cfg.entryIndex, idx);
/** 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);
});
});