mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-03 02:21:44 +00:00
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:
parent
6e0c1e1e7f
commit
d33c69f799
2 changed files with 843 additions and 0 deletions
520
gitnexus/src/core/ingestion/cfg/visitors/typescript.ts
Normal file
520
gitnexus/src/core/ingestion/cfg/visitors/typescript.ts
Normal 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 };
|
||||
323
gitnexus/test/unit/cfg/typescript-visitor.test.ts
Normal file
323
gitnexus/test/unit/cfg/typescript-visitor.test.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue