diff --git a/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts new file mode 100644 index 000000000..78b632158 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/visitors/typescript.ts @@ -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_` 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 { + return { buildFunctionCfg }; +} + +export { TS_FUNCTION_TYPES }; diff --git a/gitnexus/test/unit/cfg/typescript-visitor.test.ts b/gitnexus/test/unit/cfg/typescript-visitor.test.ts new file mode 100644 index 000000000..545f47644 --- /dev/null +++ b/gitnexus/test/unit/cfg/typescript-visitor.test.ts @@ -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 => 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(); + 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 { + 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); + } + } + }); +});