feat(ingestion): U2 — TS/JS CFG visitor over tree-sitter AST (#2081)

Add the TS/JS CfgVisitor that walks a function's tree-sitter AST and drives
the U1 CfgBuilder to produce a serializable FunctionCfg. One visitor covers
both languages (shared grammar family).

Handles the classic CFG hazards explicitly (R2, R10):
- loops allocate a dedicated loop-exit block so `break` has a concrete target
  before the loop's successor is known; `continue`/back-edge close the loop
  (while, do-while, C-for with init-once + increment-as-continue-target,
  for-in, for-of)
- switch fallthrough falls out naturally: a non-breaking case yields exits we
  wire to the next case as `fallthrough`; a breaking case wires to the switch
  exit via ControlFlowContext
- try/catch/finally: normal completion AND exceptional flow both route through
  finally (post-domination); a conservative exceptional edge models that the
  protected region may raise to its handler (not just explicit `throw`)
- labeled break/continue resolve against the labeled loop's frame
- early return/throw wire to EXIT/handler and terminate their block

19 hazard tests (one per construct) + AC1 10-function fixture; all green.
No change to the committed U1 core or ControlFlowContext.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-08 19:16:48 +00:00
parent 6e0c1e1e7f
commit d33c69f799
2 changed files with 843 additions and 0 deletions

View file

@ -0,0 +1,520 @@
/**
* TS/JS CfgVisitor (issue #2081, M1).
*
* Walks a TypeScript/JavaScript function's tree-sitter AST and drives the
* language-agnostic {@link CfgBuilder} to produce a serializable
* {@link FunctionCfg}. TS and JS share a grammar family (tree-sitter-typescript
* reuses tree-sitter-javascript's statement nodes), so one visitor covers both.
*
* Design — a `visit_<node_type>` dispatch over the statement taxonomy. The
* classic CFG hazards (R10) are handled explicitly:
* - loops allocate a dedicated **loop-exit** block so `break` has a concrete
* target before the loop's successor is known; `continue` targets the
* header/increment; the back-edge closes the loop.
* - `switch` cases fall through naturally: a case body that does not `break`
* yields non-empty `exits`, which we wire to the next case as `fallthrough`;
* a case that `break`s wires to the switch exit (via {@link ControlFlowContext})
* and yields no fall-out.
* - `try/catch/finally` routes both normal completion AND a `throw` in the try
* through `finally` (the finally block post-dominates the try/catch); a
* `throw` with no catch propagates through finally to the enclosing handler.
* - labeled `break`/`continue` resolve against the labeled loop's frame.
*
* Block/edge accounting and reachability are pinned in
* `test/unit/cfg/cfg-builder.test.ts` (core) and
* `test/unit/cfg/typescript-visitor.test.ts` (this visitor, per hazard).
*/
import type { SyntaxNode } from '../../utils/ast-helpers.js';
import { CfgBuilder } from '../cfg-builder.js';
import { ControlFlowContext } from '../control-flow-context.js';
import type { TraversalResult } from '../traversal-result.js';
import type { CfgVisitor, FunctionCfg } from '../types.js';
/** TS/JS node types that own a CFG-bearing function body. */
const TS_FUNCTION_TYPES = new Set([
'function_declaration',
'function_expression',
'arrow_function',
'method_definition',
'generator_function_declaration',
'generator_function',
'async_function_declaration',
'async_arrow_function',
]);
/** Statement node types that break a basic block (everything else coalesces). */
const CONTROL_FLOW_TYPES = new Set([
'if_statement',
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
'try_statement',
'return_statement',
'break_statement',
'continue_statement',
'throw_statement',
'labeled_statement',
'statement_block',
]);
const LOOP_OR_SWITCH_TYPES = new Set([
'while_statement',
'do_statement',
'for_statement',
'for_in_statement',
'for_of_statement',
'switch_statement',
]);
const startLineOf = (n: SyntaxNode): number => n.startPosition.row + 1;
const endLineOf = (n: SyntaxNode): number => n.endPosition.row + 1;
/** A statement sequence that produced no blocks (empty body) is "transparent". */
type SeqResult = TraversalResult | null;
/**
* Per-function walk state. One instance is created per function so the
* {@link ControlFlowContext}, exception-handler stack, and pending label are
* scoped to that function and never leak across functions.
*/
class TsCfgWalk {
private readonly cfc = new ControlFlowContext();
/** Stack of exception-handler entry blocks (catch/finally) a `throw` jumps to. */
private readonly handlers: number[] = [];
/** Label awaiting the loop/switch it immediately precedes (labeled_statement). */
private pendingLabel: string | undefined;
constructor(private readonly builder: CfgBuilder) {}
/** Statements of a block node, ignoring comments. */
private statementsOf(block: SyntaxNode): SyntaxNode[] {
return block.namedChildren.filter((c) => c.type !== 'comment');
}
/** The `body` block of a node (field, or the first statement_block child). */
private bodyBlockOf(node: SyntaxNode): SyntaxNode | undefined {
return (
node.childForFieldName('body') ?? node.namedChildren.find((c) => c.type === 'statement_block')
);
}
/** Visit a body that may be a `statement_block` or a single statement. */
private visitBody(node: SyntaxNode | undefined | null): SeqResult {
if (!node) return null;
if (node.type === 'statement_block') return this.visitSeq(this.statementsOf(node));
return this.visitStmt(node);
}
/** Wire a sequence of statements, coalescing straight-line runs into blocks. */
visitSeq(stmts: SyntaxNode[]): SeqResult {
let entry: number | undefined;
let dangling: number[] = [];
let openSimple: number | undefined;
for (const stmt of stmts) {
if (CONTROL_FLOW_TYPES.has(stmt.type)) {
openSimple = undefined; // close any open straight-line block
const res = this.visitStmt(stmt);
if (res === null) continue; // transparent (empty nested block)
if (entry === undefined) entry = res.entry;
else this.builder.connect(dangling, res.entry, 'seq');
dangling = [...res.exits];
} else {
// Simple statement — coalesce into the current straight-line block.
if (openSimple === undefined) {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
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);
}
}
}
if (entry === undefined) return null;
return { entry, exits: dangling };
}
/** Dispatch one statement to its handler. Non-null except for empty blocks. */
visitStmt(stmt: SyntaxNode): SeqResult {
switch (stmt.type) {
case 'if_statement':
return this.visitIf(stmt);
case 'while_statement':
return this.visitWhile(stmt);
case 'do_statement':
return this.visitDoWhile(stmt);
case 'for_statement':
return this.visitFor(stmt);
case 'for_in_statement':
case 'for_of_statement':
return this.visitForIn(stmt);
case 'switch_statement':
return this.visitSwitch(stmt);
case 'try_statement':
return this.visitTry(stmt);
case 'return_statement':
return this.visitReturn(stmt);
case 'throw_statement':
return this.visitThrow(stmt);
case 'break_statement':
return this.visitBreak(stmt);
case 'continue_statement':
return this.visitContinue(stmt);
case 'labeled_statement':
return this.visitLabeled(stmt);
case 'statement_block':
return this.visitSeq(this.statementsOf(stmt));
default:
return this.visitSimple(stmt);
}
}
private visitSimple(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
return { entry: idx, exits: [idx] };
}
private visitReturn(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.builder.edge(idx, this.builder.exitIndex, 'return');
return { entry: idx, exits: [] };
}
private visitThrow(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
this.builder.edge(idx, this.currentHandler(), 'throw');
return { entry: idx, exits: [] };
}
private visitBreak(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.cfc.breakTarget(this.labelOf(stmt));
if (target !== undefined) this.builder.edge(idx, target, 'break');
return { entry: idx, exits: [] };
}
private visitContinue(stmt: SyntaxNode): TraversalResult {
const idx = this.builder.newBlock(startLineOf(stmt), endLineOf(stmt), stmt.text);
const target = this.cfc.continueTarget(this.labelOf(stmt));
if (target !== undefined) this.builder.edge(idx, target, 'continue');
return { entry: idx, exits: [] };
}
private visitLabeled(stmt: SyntaxNode): SeqResult {
const body =
stmt.childForFieldName('body') ?? stmt.namedChildren[stmt.namedChildren.length - 1];
if (body && LOOP_OR_SWITCH_TYPES.has(body.type)) {
this.pendingLabel = this.labelOf(stmt);
const res = this.visitStmt(body);
this.pendingLabel = undefined; // clear even if the construct didn't consume it
return res;
}
// Labeled non-loop blocks (break-to-block-label) are not modeled in M1.
return this.visitBody(body);
}
private visitIf(stmt: SyntaxNode): TraversalResult {
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
const exits: number[] = [];
const thenRes = this.visitBody(stmt.childForFieldName('consequence'));
if (thenRes) {
this.builder.edge(condBlock, thenRes.entry, 'cond-true');
exits.push(...thenRes.exits);
} else {
exits.push(condBlock); // empty then — true path falls through
}
const elseNode = this.elseBodyOf(stmt);
if (elseNode) {
const elseRes = this.visitBody(elseNode);
if (elseRes) {
this.builder.edge(condBlock, elseRes.entry, 'cond-false');
exits.push(...elseRes.exits);
} else {
exits.push(condBlock); // empty else block
}
} else {
exits.push(condBlock); // no else — false path falls through to the join
}
return { entry: condBlock, exits: [...new Set(exits)] };
}
/** The else body node (unwraps an `else_clause` wrapper if present). */
private elseBodyOf(ifStmt: SyntaxNode): SyntaxNode | undefined {
const alt = ifStmt.childForFieldName('alternative');
if (!alt) return undefined;
if (alt.type === 'else_clause') {
return alt.childForFieldName('body') ?? alt.namedChildren[0];
}
return alt;
}
private visitWhile(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const cond = stmt.childForFieldName('condition') ?? stmt;
const header = this.builder.newBlock(startLineOf(stmt), endLineOf(cond), cond.text);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back'); // empty body re-tests
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private visitDoWhile(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const cond = stmt.childForFieldName('condition') ?? stmt;
const condBlock = this.builder.newBlock(startLineOf(cond), endLineOf(cond), cond.text);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(condBlock, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
const backTarget = body ? body.entry : condBlock;
if (body) this.builder.connect(body.exits, condBlock, 'seq');
this.builder.edge(condBlock, backTarget, 'loop-back'); // cond true → run body again
this.builder.edge(condBlock, loopExit, 'cond-false');
return { entry: backTarget, exits: [loopExit] };
}
private visitFor(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const init = stmt.childForFieldName('initializer');
const cond = stmt.childForFieldName('condition');
const incr = stmt.childForFieldName('increment');
const header = this.builder.newBlock(
startLineOf(stmt),
cond ? endLineOf(cond) : startLineOf(stmt),
cond ? cond.text : 'for(;;)',
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
let incrBlock = header;
if (incr) {
incrBlock = this.builder.newBlock(startLineOf(incr), endLineOf(incr), incr.text);
this.builder.edge(incrBlock, header, 'loop-back');
}
this.cfc.pushLoop(incrBlock, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, incrBlock, 'seq');
} else {
this.builder.edge(header, incrBlock, 'cond-true');
}
if (incrBlock === header) this.builder.edge(header, header, 'loop-back');
this.builder.edge(header, loopExit, 'cond-false');
let entry = header;
if (init) {
const initBlock = this.builder.newBlock(startLineOf(init), endLineOf(init), init.text);
this.builder.edge(initBlock, header, 'seq');
entry = initBlock;
}
return { entry, exits: [loopExit] };
}
private visitForIn(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const header = this.builder.newBlock(
startLineOf(stmt),
startLineOf(stmt),
this.forInHeaderText(stmt),
);
const loopExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushLoop(header, loopExit, label);
const body = this.visitBody(this.bodyBlockOf(stmt));
this.cfc.pop();
if (body) {
this.builder.edge(header, body.entry, 'cond-true');
this.builder.connect(body.exits, header, 'loop-back');
} else {
this.builder.edge(header, header, 'loop-back');
}
this.builder.edge(header, loopExit, 'cond-false');
return { entry: header, exits: [loopExit] };
}
private forInHeaderText(stmt: SyntaxNode): string {
const left = stmt.childForFieldName('left')?.text ?? '';
const right = stmt.childForFieldName('right')?.text ?? '';
return left || right ? `for(${left} … ${right})` : 'for(… in/of …)';
}
private visitSwitch(stmt: SyntaxNode): TraversalResult {
const label = this.takeLabel();
const value = stmt.childForFieldName('value') ?? stmt;
const dispatch = this.builder.newBlock(startLineOf(stmt), endLineOf(value), value.text);
const switchExit = this.builder.newBlock(endLineOf(stmt), endLineOf(stmt), '');
this.cfc.pushSwitch(switchExit, label);
const body = stmt.childForFieldName('body');
const cases = body
? body.namedChildren.filter((c) => c.type === 'switch_case' || c.type === 'switch_default')
: [];
const caseResults = cases.map((c) => this.visitSeq(this.caseStatements(c)));
const hasDefault = cases.some((c) => c.type === 'switch_default');
// entryOf[i] = block a dispatch/fallthrough INTO case i lands on (empty
// cases are transparent — they resolve to the next case, or the exit).
const entryOf: number[] = new Array(cases.length);
let after = switchExit;
for (let i = cases.length - 1; i >= 0; i--) {
entryOf[i] = caseResults[i]?.entry ?? after;
after = entryOf[i];
}
for (let i = 0; i < cases.length; i++) {
this.builder.edge(dispatch, entryOf[i], 'switch-case');
}
if (!hasDefault) this.builder.edge(dispatch, switchExit, 'switch-case'); // no-match path
for (let i = 0; i < cases.length; i++) {
const res = caseResults[i];
if (!res) continue;
const fallTarget = i + 1 < cases.length ? entryOf[i + 1] : switchExit;
this.builder.connect(res.exits, fallTarget, 'fallthrough');
}
this.cfc.pop();
return { entry: dispatch, exits: [switchExit] };
}
private caseStatements(caseNode: SyntaxNode): SyntaxNode[] {
const value = caseNode.childForFieldName('value');
return caseNode.namedChildren.filter((c) => c.id !== value?.id && c.type !== 'comment');
}
private visitTry(stmt: SyntaxNode): SeqResult {
const bodyNode = stmt.childForFieldName('body');
const catchClause = stmt.namedChildren.find((c) => c.type === 'catch_clause');
const finallyClause = stmt.namedChildren.find((c) => c.type === 'finally_clause');
// Build finally first so its entry is known as both a normal join and a
// handler target. The finally body runs in the OUTER handler context.
const finallyRes = finallyClause
? this.visitSeq(this.statementsOf(this.bodyBlockOf(finallyClause) as SyntaxNode))
: null;
// A throw inside catch propagates to finally (if any), else the outer handler.
let catchRes: SeqResult = null;
if (catchClause) {
if (finallyRes) this.handlers.push(finallyRes.entry);
catchRes = this.visitSeq(this.statementsOf(this.bodyBlockOf(catchClause) as SyntaxNode));
if (finallyRes) this.handlers.pop();
}
// Handler for the try body: catch if present, else finally, else outer.
const tryHandler = catchRes?.entry ?? finallyRes?.entry ?? this.currentHandler();
this.handlers.push(tryHandler);
const bodyRes = bodyNode ? this.visitSeq(this.statementsOf(bodyNode)) : null;
this.handlers.pop();
// Conservative exceptional edge: any statement in the protected region may
// raise to the handler, not just an explicit `throw`. Without this the
// catch/finally would be unreachable for the common `try { call() } catch`
// shape (the exception originates inside a callee). Explicit `throw`s in the
// body also add their own precise edge to the same handler (idempotent).
if (bodyRes && (catchClause || finallyClause)) {
this.builder.edge(bodyRes.entry, tryHandler, 'throw');
}
const exits: number[] = [];
if (finallyRes) {
// Normal completion of try AND catch both flow through finally.
if (bodyRes) this.builder.connect(bodyRes.exits, finallyRes.entry, 'seq');
if (catchRes) this.builder.connect(catchRes.exits, finallyRes.entry, 'seq');
exits.push(...finallyRes.exits);
// No catch → an exception re-propagates out after finally runs.
if (!catchRes) this.builder.connect(finallyRes.exits, this.currentHandler(), 'throw');
} else {
if (bodyRes) exits.push(...bodyRes.exits);
if (catchRes) exits.push(...catchRes.exits);
}
const entry = bodyRes?.entry ?? finallyRes?.entry ?? catchRes?.entry;
if (entry === undefined) 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;
}
/** Consume the label awaiting the loop/switch this call is building. */
private takeLabel(): string | undefined {
const label = this.pendingLabel;
this.pendingLabel = undefined;
return label;
}
private labelOf(stmt: SyntaxNode): string | undefined {
const id =
stmt.childForFieldName('label') ??
stmt.namedChildren.find((c) => c.type === 'statement_identifier');
return id?.text;
}
}
/** Build the CFG for one TS/JS function node (or `undefined` if not a function). */
function buildFunctionCfg(fnNode: SyntaxNode, filePath: string): FunctionCfg | undefined {
if (!TS_FUNCTION_TYPES.has(fnNode.type)) return undefined;
const startLine = startLineOf(fnNode);
const endLine = endLineOf(fnNode);
const builder = new CfgBuilder(filePath, startLine, endLine);
const body = fnNode.childForFieldName('body');
if (!body) return undefined; // overload signature / abstract method — no body
if (body.type !== 'statement_block') {
// Expression-bodied arrow: `() => expr` — one block whose value is returned.
const blk = builder.newBlock(startLineOf(body), endLineOf(body), body.text);
builder.edge(builder.entryIndex, blk, 'seq');
builder.edge(blk, builder.exitIndex, 'return');
return builder.finish();
}
const walk = new TsCfgWalk(builder);
const res = walk.visitSeq(body.namedChildren.filter((c) => c.type !== 'comment'));
if (!res) {
builder.edge(builder.entryIndex, builder.exitIndex, 'seq'); // empty body
return builder.finish();
}
builder.edge(builder.entryIndex, res.entry, 'seq');
builder.connect(res.exits, builder.exitIndex, 'seq'); // normal fall-off → EXIT
return builder.finish();
}
/** The TS/JS CFG visitor (shared by TypeScript and JavaScript). */
export function createTypeScriptCfgVisitor(): CfgVisitor<SyntaxNode> {
return { buildFunctionCfg };
}
export { TS_FUNCTION_TYPES };

View file

@ -0,0 +1,323 @@
import { describe, it, expect } from 'vitest';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import {
createTypeScriptCfgVisitor,
TS_FUNCTION_TYPES,
} from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
// U2 — the TS/JS CfgVisitor, one hazard per test. Each fixture's distinctive
// statement text (markerWork(), handleErr(), cleanup(), …) lets us find the
// block for a region by text and assert the control-flow topology around it
// (R2, R10). The classic CFG hazards — loops/back-edges, switch fallthrough,
// try/finally post-domination, labeled jumps — are where builders break.
const visitor = createTypeScriptCfgVisitor();
function parse(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
function collectFunctions(root: SyntaxNode): SyntaxNode[] {
const out: SyntaxNode[] = [];
const stack = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (TS_FUNCTION_TYPES.has(n.type)) out.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return out;
}
/** Build the CFG for the first (outermost-first by traversal) function in code. */
function cfgOf(code: string, index = 0): FunctionCfg {
const fns = collectFunctions(parse(code));
const fn = fns[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
}
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));
/** Does control reach `to` from `from` following edges? */
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);
describe('TS/JS CfgVisitor — structure', () => {
it('straight-line body: ENTRY → block → EXIT', () => {
const cfg = cfgOf(`function f() { a(); b(); c(); }`);
// a/b/c coalesce into one basic block
expect(cfg.blocks.filter((b) => b.kind === 'normal')).toHaveLength(1);
const body = block(cfg, 'a();');
expect(reaches(cfg, cfg.entryIndex, body)).toBe(true);
expect(reaches(cfg, body, cfg.exitIndex)).toBe(true);
});
it('empty body: ENTRY → EXIT', () => {
const cfg = cfgOf(`function f() {}`);
expect(cfg.blocks).toHaveLength(2);
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
});
it('expression-bodied arrow returns its expression', () => {
const cfg = cfgOf(`const f = (x: number) => x + 1;`);
const expr = block(cfg, 'x + 1');
expect(cfg.edges).toContainEqual({ from: expr, to: cfg.exitIndex, kind: 'return' });
});
});
describe('TS/JS CfgVisitor — branching', () => {
it('if/else diamond emits cond-true + cond-false, both reach the join', () => {
const cfg = cfgOf(`function f(x) { if (x) { a(); } else { b(); } c(); }`);
const kinds = edgeKinds(cfg);
expect(kinds.has('cond-true')).toBe(true);
expect(kinds.has('cond-false')).toBe(true);
const join = block(cfg, 'c();');
expect(reaches(cfg, block(cfg, 'a();'), join)).toBe(true);
expect(reaches(cfg, block(cfg, 'b();'), join)).toBe(true);
});
it('else-if chain: all three arms reachable and rejoin', () => {
const cfg = cfgOf(`function f(x) {
if (x === 1) { a(); }
else if (x === 2) { b(); }
else { c(); }
d();
}`);
const join = block(cfg, 'd();');
for (const arm of ['a();', 'b();', 'c();']) {
expect(reachable(cfg, block(cfg, arm))).toBe(true);
expect(reaches(cfg, block(cfg, arm), join)).toBe(true);
}
});
it('plain if (no else): condition reaches both the body and the join', () => {
const cfg = cfgOf(`function f(x) { if (x) { a(); } b(); }`);
const cond = block(cfg, 'x'); // condition block
const then = block(cfg, 'a();');
const join = block(cfg, 'b();');
expect(reaches(cfg, cond, then)).toBe(true);
expect(reaches(cfg, cond, join)).toBe(true);
expect(reaches(cfg, then, join)).toBe(true);
});
});
describe('TS/JS CfgVisitor — loops', () => {
it('while loop has a back-edge and an exit', () => {
const cfg = cfgOf(`function f(x) { while (x > 0) { step(); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
const body = block(cfg, 'step();');
const header = block(cfg, 'x > 0');
expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' });
expect(reaches(cfg, header, block(cfg, 'done();'))).toBe(true);
});
it('do-while runs the body before testing and loops back', () => {
const cfg = cfgOf(`function f(x) { do { step(); } while (x > 0); done(); }`);
const body = block(cfg, 'step();');
expect(reaches(cfg, cfg.entryIndex, body)).toBe(true); // body runs first
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, body, block(cfg, 'done();'))).toBe(true);
});
it('C-style for: init once, condition header, back-edge through increment', () => {
const cfg = cfgOf(`function f() { for (let i = 0; i < n; i++) { step(); } done(); }`);
const init = block(cfg, 'let i = 0');
const incr = block(cfg, 'i++');
const body = block(cfg, 'step();');
expect(cfg.edges).toContainEqual({ from: cfg.entryIndex, to: init, kind: 'seq' });
expect(reaches(cfg, body, incr)).toBe(true); // body → increment
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, incr, block(cfg, 'done();'))).toBe(true);
});
it('for-of loop builds a header/back-edge/exit', () => {
const cfg = cfgOf(`function f(xs) { for (const x of xs) { use(x); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, block(cfg, 'use(x)'), block(cfg, 'done();'))).toBe(true);
});
it('for-in loop builds a header/back-edge/exit', () => {
const cfg = cfgOf(`function f(o) { for (const k in o) { use(k); } done(); }`);
expect(edgeKinds(cfg).has('loop-back')).toBe(true);
expect(reaches(cfg, block(cfg, 'use(k)'), block(cfg, 'done();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — switch', () => {
it('break-terminated cases dispatch to the exit, no fallthrough', () => {
const cfg = cfgOf(`function f(x) {
switch (x) {
case 1: one(); break;
case 2: two(); break;
default: other();
}
after();
}`);
expect(edgeKinds(cfg).has('switch-case')).toBe(true);
const after = block(cfg, 'after();');
expect(reaches(cfg, block(cfg, 'one();'), after)).toBe(true);
expect(reaches(cfg, block(cfg, 'two();'), after)).toBe(true);
// case 1 does NOT fall into case 2 (break severs it)
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(false);
});
it('fallthrough: a case without break flows into the next case', () => {
const cfg = cfgOf(`function f(x) {
switch (x) {
case 1: one();
case 2: two(); break;
}
after();
}`);
expect(edgeKinds(cfg).has('fallthrough')).toBe(true);
expect(reaches(cfg, block(cfg, 'one();'), block(cfg, 'two();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — try/catch/finally (R10)', () => {
it('normal completion AND a throw both flow through finally; finally reaches the post-try block', () => {
const cfg = cfgOf(`function f() {
try {
work();
risky();
} catch (e) {
handleErr();
} finally {
cleanup();
}
afterTry();
}`);
const fin = block(cfg, 'cleanup();');
const after = block(cfg, 'afterTry();');
const work = block(cfg, 'work();');
const handler = block(cfg, 'handleErr();');
expect(edgeKinds(cfg).has('throw')).toBe(true);
// normal path: try body → finally
expect(reaches(cfg, work, fin)).toBe(true);
// exceptional path: try body → catch → finally
expect(reaches(cfg, work, handler)).toBe(true);
expect(reaches(cfg, handler, fin)).toBe(true);
// finally post-dominates and reaches the continuation
expect(reaches(cfg, fin, after)).toBe(true);
});
it('try/finally with no catch: a throw still flows through finally', () => {
const cfg = cfgOf(`function f() {
try { risky(); } finally { cleanup(); }
afterTry();
}`);
const fin = block(cfg, 'cleanup();');
expect(reaches(cfg, block(cfg, 'risky();'), fin)).toBe(true);
expect(reaches(cfg, fin, block(cfg, 'afterTry();'))).toBe(true);
});
});
describe('TS/JS CfgVisitor — non-local jumps (R10)', () => {
it('early return wires to EXIT and ends its block', () => {
const cfg = cfgOf(`function f(x) { if (x) { return; } tail(); }`);
const ret = block(cfg, 'return;');
expect(cfg.edges).toContainEqual({ from: ret, to: cfg.exitIndex, kind: 'return' });
});
it('labeled break resolves to the outer loop exit, not the inner loop', () => {
const cfg = cfgOf(`function f(xs, ys) {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) { break outer; }
inner();
}
afterInner();
}
done();
}`);
expect(edgeKinds(cfg).has('break')).toBe(true);
const brk = block(cfg, 'break outer;');
const done = block(cfg, 'done();');
// break outer escapes BOTH loops → reaches the post-loop block
expect(reaches(cfg, brk, done)).toBe(true);
// and does NOT route back through afterInner() (that's the inner loop's normal exit)
expect(reaches(cfg, brk, block(cfg, 'afterInner();'))).toBe(false);
});
it('labeled continue resolves to the labeled loop header', () => {
const cfg = cfgOf(`function f(xs, ys) {
outer: for (const x of xs) {
for (const y of ys) {
if (x === y) { continue outer; }
inner();
}
}
}`);
expect(edgeKinds(cfg).has('continue')).toBe(true);
const cont = block(cfg, 'continue outer;');
const outerHeader = block(cfg, 'x … xs');
expect(
cfg.edges.some((e) => e.from === cont && e.to === outerHeader && e.kind === 'continue'),
).toBe(true);
});
});
describe('TS/JS CfgVisitor — AC1: 10-function fixture', () => {
const TEN_FN = `
function straight() { a(); b(); }
function withIf(x) { if (x) { a(); } else { b(); } }
function withElseIf(x) { if (x===1) { a(); } else if (x===2) { b(); } else { c(); } }
function withWhile(x) { while (x) { step(); } }
function withFor() { for (let i=0;i<n;i++) { step(); } }
function withForOf(xs) { for (const x of xs) { use(x); } }
function withSwitch(x) { switch (x) { case 1: one(); break; default: other(); } }
function withTry() { try { work(); } catch (e) { oops(); } finally { fin(); } }
function withReturn(x) { if (x) { return 1; } return 2; }
function withLabeled(xs, ys) { outer: for (const x of xs) { for (const y of ys) { break outer; } } }
`;
it('produces one CFG per function, each with a reachable EXIT and contiguous block indices', () => {
const fns = collectFunctions(parse(TEN_FN)).filter((f) => f.type === 'function_declaration');
expect(fns).toHaveLength(10);
for (const fn of fns) {
const cfg = visitor.buildFunctionCfg(fn, 'fixture.ts');
expect(cfg).toBeDefined();
if (!cfg) continue;
// ENTRY is index 0; indices are contiguous 0..n-1
expect(cfg.blocks.map((b) => b.index)).toEqual(cfg.blocks.map((_, i) => i));
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit');
// EXIT is reachable from ENTRY for every function
expect(reaches(cfg, cfg.entryIndex, cfg.exitIndex)).toBe(true);
// No edge endpoint is out of range
for (const e of cfg.edges) {
expect(e.from).toBeGreaterThanOrEqual(0);
expect(e.to).toBeLessThan(cfg.blocks.length);
}
}
});
});