feat(cfg): language-agnostic CFG construction core (#2081)

U1 of M1 (CFG layer). Plain JSON-serializable CFG data model (BasicBlockData/
CfgEdgeData/FunctionCfg — must survive the worker→main boundary + ParsedFile
store), a CfgBuilder accumulator (leaders→blocks→edges, synthetic ENTRY/EXIT,
idempotent edges), a ControlFlowContext (break/continue/switch + labeled-jump
target stacks), and a TraversalResult ({entry, dangling exits}). AST-agnostic
and unit-tested on the classic control-flow topologies (if/else, while back-edge,
mid-block return, labeled break/continue) the S2 spike validated; reachability
helper backs the R9 property test.
This commit is contained in:
Gergo Magyar 2026-06-08 18:36:40 +00:00
parent f2c9e69792
commit 6e0c1e1e7f
5 changed files with 389 additions and 0 deletions

View file

@ -0,0 +1,114 @@
/**
* CfgBuilder (issue #2081, M1) — the language-agnostic accumulator.
*
* A per-language `CfgVisitor` drives this: it creates blocks as it walks
* statements, wires edges (including back-edges and break/continue/return/throw
* targets resolved via {@link ControlFlowContext}), and calls {@link finish} to
* produce the serializable {@link FunctionCfg}. The builder owns the synthetic
* ENTRY (index 0) and EXIT blocks and de-duplicates identical edges so repeated
* `connect` calls (common when wiring a set of dangling exits) stay idempotent.
*
* It has no knowledge of any AST — it is exercised directly in unit tests with
* hand-built block sequences, which is how the classic CFG hazards are pinned
* before the tree-sitter visitor (U2) drives it.
*/
import type { BasicBlockData, CfgEdgeData, CfgEdgeKind, FunctionCfg } from './types.js';
interface MutableBlock {
startLine: number;
endLine: number;
text: string;
kind: BasicBlockData['kind'];
}
export class CfgBuilder {
private readonly blocks: MutableBlock[] = [];
private readonly edges: CfgEdgeData[] = [];
private readonly edgeKeys = new Set<string>();
readonly entryIndex: number;
readonly exitIndex: number;
constructor(
private readonly filePath: string,
private readonly functionStartLine: number,
private readonly functionEndLine: number,
) {
this.entryIndex = this.newBlock(functionStartLine, functionStartLine, '', 'entry');
this.exitIndex = this.newBlock(functionEndLine, functionEndLine, '', 'exit');
}
/** Create a block and return its index. */
newBlock(
startLine: number,
endLine: number,
text: string,
kind: BasicBlockData['kind'] = 'normal',
): number {
this.blocks.push({ startLine, endLine, text, kind });
return this.blocks.length - 1;
}
/** Add a single edge (idempotent on from+to+kind). */
edge(from: number, to: number, kind: CfgEdgeKind): void {
const key = `${from}->${to}:${kind}`;
if (this.edgeKeys.has(key)) return;
this.edgeKeys.add(key);
this.edges.push({ from, to, kind });
}
/** Wire a set of dangling exits to a single target block with one kind. */
connect(exits: readonly number[], to: number, kind: CfgEdgeKind = 'seq'): void {
for (const from of exits) this.edge(from, to, kind);
}
/** Extend a block's end line as more statements accrue to it. */
extendBlock(index: number, endLine: number, appendText?: string): void {
const b = this.blocks[index];
if (!b) return;
if (endLine > b.endLine) b.endLine = endLine;
if (appendText) b.text = b.text ? `${b.text}\n${appendText}` : appendText;
}
get blockCount(): number {
return this.blocks.length;
}
/** Produce the serializable CFG. Caller is responsible for having wired the
* function's dangling exits to {@link exitIndex} before calling. */
finish(): FunctionCfg {
return {
filePath: this.filePath,
functionStartLine: this.functionStartLine,
functionEndLine: this.functionEndLine,
entryIndex: this.entryIndex,
exitIndex: this.exitIndex,
blocks: this.blocks.map((b, index) => ({ index, ...b })),
edges: [...this.edges],
};
}
}
/**
* Block indices reachable from `entryIndex` by following edges. Used by the
* reachability property test (R9) and as a self-check in the emit step.
*/
export const reachableBlocks = (cfg: FunctionCfg): Set<number> => {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) {
const list = adj.get(e.from);
if (list) list.push(e.to);
else adj.set(e.from, [e.to]);
}
const seen = new Set<number>([cfg.entryIndex]);
const stack = [cfg.entryIndex];
while (stack.length) {
const n = stack.pop() as number;
for (const next of adj.get(n) ?? []) {
if (!seen.has(next)) {
seen.add(next);
stack.push(next);
}
}
}
return seen;
};

View file

@ -0,0 +1,70 @@
/**
* ControlFlowContext (issue #2081, M1).
*
* Resolves the targets of `break`/`continue` (plain and labeled) as the visitor
* descends through loops and switches. Loops and switches push a target frame
* on entry and pop it on exit; a labeled statement attaches its label to the
* frame of the construct it labels, so `break outer` / `continue outer` resolve
* against the right enclosing loop/switch rather than the nearest one.
*/
interface LoopFrame {
readonly kind: 'loop';
/** Block a `continue` jumps to (the loop header / update). */
readonly continueTo: number;
/** Block a `break` jumps to (the loop exit / join). */
readonly breakTo: number;
readonly label?: string;
}
interface SwitchFrame {
readonly kind: 'switch';
/** Block a `break` jumps to (after the switch). `continue` is invalid here. */
readonly breakTo: number;
readonly label?: string;
}
type Frame = LoopFrame | SwitchFrame;
export class ControlFlowContext {
private readonly stack: Frame[] = [];
pushLoop(continueTo: number, breakTo: number, label?: string): void {
this.stack.push({ kind: 'loop', continueTo, breakTo, label });
}
pushSwitch(breakTo: number, label?: string): void {
this.stack.push({ kind: 'switch', breakTo, label });
}
pop(): void {
this.stack.pop();
}
/**
* Target block for a `break`. With a label, the nearest enclosing frame
* carrying that label (loop or switch); without, the nearest frame of any
* kind. Returns `undefined` if there is no valid target (malformed input).
*/
breakTarget(label?: string): number | undefined {
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (label === undefined || f.label === label) return f.breakTo;
}
return undefined;
}
/**
* Target block for a `continue`. With a label, the nearest enclosing **loop**
* carrying that label; without, the nearest loop (switches are skipped — you
* cannot `continue` a switch). Returns `undefined` if there is no valid loop.
*/
continueTarget(label?: string): number | undefined {
for (let i = this.stack.length - 1; i >= 0; i--) {
const f = this.stack[i];
if (f.kind !== 'loop') continue;
if (label === undefined || f.label === label) return f.continueTo;
}
return undefined;
}
}

View file

@ -0,0 +1,21 @@
/**
* TraversalResult (issue #2081, M1).
*
* Visiting a statement (or a statement sequence) returns the block its control
* flow ENTERS through, plus the set of blocks whose **normal** control flows
* out the bottom (the "dangling exits") — to be wired to the entry of whatever
* comes next. Abnormal exits (return/break/continue/throw) are wired directly
* to their targets during the walk and are NOT part of `exits`.
*
* A statement that cannot fall through (e.g. ends in `return`/`throw`, or both
* branches of an `if` return) yields an empty `exits` array.
*/
export interface TraversalResult {
/** Block index control enters this statement/sequence through. */
readonly entry: number;
/** Block indices whose normal control falls out the bottom (may be empty). */
readonly exits: readonly number[];
}
/** A sequence of statements that produced no blocks (e.g. an empty body). */
export const emptyTraversal = (entry: number): TraversalResult => ({ entry, exits: [entry] });

View file

@ -0,0 +1,64 @@
/**
* CFG data model — plain, JSON-serializable types (issue #2081, M1).
*
* These cross the worker→main boundary and the disk-backed/durable ParsedFile
* store, so they must contain NO tree-sitter AST references, class instances,
* or anything that does not survive `JSON.stringify` → `JSON.parse`. Block and
* edge endpoints are referenced by integer index within a function's CFG.
*
* The per-language `CfgVisitor` (built in the parse worker, where the AST
* lives — see the M1 plan KTD1/KTD7) produces a `FunctionCfg` per function; the
* array of them is what rides on `ParsedFile.cfgSideChannel`.
*/
/** A basic block: a maximal straight-line run of statements between leaders. */
export interface BasicBlockData {
/** Block index within its function. The synthetic ENTRY is always 0. */
readonly index: number;
readonly startLine: number;
readonly endLine: number;
/** Source snippet for the block (empty for synthetic ENTRY/EXIT). */
readonly text: string;
readonly kind: 'entry' | 'exit' | 'normal';
}
/** Why one block flows to another — drives the `reason` on the emitted CFG edge. */
export type CfgEdgeKind =
| 'seq' // straight-line fallthrough
| 'cond-true' // branch taken (if/while/for condition true)
| 'cond-false' // branch not taken / loop exit
| 'loop-back' // back-edge to a loop header
| 'break' // break → loop/switch exit
| 'continue' // continue → loop header
| 'return' // return → function EXIT
| 'throw' // throw → nearest handler / finally / EXIT
| 'switch-case' // dispatch to a case
| 'fallthrough'; // switch case → next case (no break)
export interface CfgEdgeData {
readonly from: number;
readonly to: number;
readonly kind: CfgEdgeKind;
}
/** One function's control-flow graph. `cfgSideChannel` is `readonly FunctionCfg[]`. */
export interface FunctionCfg {
readonly filePath: string;
/** Source span of the owning function — anchors the BasicBlock node ids. */
readonly functionStartLine: number;
readonly functionEndLine: number;
readonly entryIndex: number;
readonly exitIndex: number;
readonly blocks: readonly BasicBlockData[];
readonly edges: readonly CfgEdgeData[];
}
/**
* Per-language CFG strategy. Invoked **in the parse worker** for each function
* node. `TNode` is the language's AST node type (tree-sitter `SyntaxNode` for
* TS/JS) — kept generic so this module stays AST-library-agnostic. Returns
* `undefined` when the node is not a CFG-bearing function (the caller skips it).
*/
export interface CfgVisitor<TNode = unknown> {
buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined;
}

View file

@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { CfgBuilder, reachableBlocks } from '../../../src/core/ingestion/cfg/cfg-builder.js';
import { ControlFlowContext } from '../../../src/core/ingestion/cfg/control-flow-context.js';
// The CFG core is AST-agnostic — these tests drive the builder + context the
// way the TS/JS visitor (U2) will, on the classic control-flow topologies the
// S2 spike validated. They pin block/edge accounting and reachability (R1, R9)
// before any tree-sitter coupling exists.
describe('CfgBuilder', () => {
it('straight-line body: ENTRY → block → EXIT, all reachable', () => {
const b = new CfgBuilder('f.ts', 1, 3);
const body = b.newBlock(2, 2, 'g();');
b.edge(b.entryIndex, body, 'seq');
b.edge(body, b.exitIndex, 'seq');
const cfg = b.finish();
expect(cfg.blocks).toHaveLength(3); // entry, exit, body
expect(cfg.entryIndex).toBe(0);
expect(reachableBlocks(cfg).size).toBe(3);
});
it('empty function: ENTRY → EXIT only', () => {
const b = new CfgBuilder('f.ts', 1, 1);
b.edge(b.entryIndex, b.exitIndex, 'seq');
const cfg = b.finish();
expect(cfg.blocks).toHaveLength(2);
expect(reachableBlocks(cfg)).toEqual(new Set([b.entryIndex, b.exitIndex]));
});
it('if/else diamond: both branches reach the join', () => {
const b = new CfgBuilder('f.ts', 1, 6);
const thenB = b.newBlock(2, 2, 'a();');
const elseB = b.newBlock(4, 4, 'b();');
const join = b.newBlock(6, 6, 'c();');
b.edge(b.entryIndex, thenB, 'cond-true');
b.edge(b.entryIndex, elseB, 'cond-false');
b.connect([thenB, elseB], join, 'seq');
b.edge(join, b.exitIndex, 'seq');
const cfg = b.finish();
const reach = reachableBlocks(cfg);
expect(reach.has(thenB) && reach.has(elseB) && reach.has(join)).toBe(true);
// join has two predecessors
expect(cfg.edges.filter((e) => e.to === join)).toHaveLength(2);
});
it('while loop: body back-edges to header; header exits the loop', () => {
const b = new CfgBuilder('f.ts', 1, 4);
const header = b.newBlock(1, 1, 'while(x)');
const body = b.newBlock(2, 2, 'x--;');
b.edge(b.entryIndex, header, 'seq');
b.edge(header, body, 'cond-true');
b.edge(body, header, 'loop-back');
b.edge(header, b.exitIndex, 'cond-false');
const cfg = b.finish();
expect(cfg.edges).toContainEqual({ from: body, to: header, kind: 'loop-back' });
expect(reachableBlocks(cfg).size).toBe(4); // all reachable
});
it('mid-block return wires to EXIT; trailing block still emitted but unreachable-by-fallthrough', () => {
const b = new CfgBuilder('f.ts', 1, 4);
const ret = b.newBlock(2, 2, 'return 1;');
const dead = b.newBlock(3, 3, 'g();'); // after return — no fallthrough edge into it
b.edge(b.entryIndex, ret, 'seq');
b.edge(ret, b.exitIndex, 'return');
b.edge(dead, b.exitIndex, 'seq');
const cfg = b.finish();
const reach = reachableBlocks(cfg);
expect(reach.has(ret)).toBe(true);
expect(reach.has(dead)).toBe(false); // emitted, but not reachable from ENTRY
});
it('edge() is idempotent on (from,to,kind)', () => {
const b = new CfgBuilder('f.ts', 1, 2);
const x = b.newBlock(1, 1, 'x');
b.edge(b.entryIndex, x, 'seq');
b.edge(b.entryIndex, x, 'seq'); // duplicate
b.connect([b.entryIndex], x, 'seq'); // duplicate via connect
expect(b.finish().edges.filter((e) => e.from === b.entryIndex && e.to === x)).toHaveLength(1);
});
it('finish() indexes blocks contiguously from 0', () => {
const b = new CfgBuilder('f.ts', 1, 2);
b.newBlock(1, 1, 'a');
b.newBlock(2, 2, 'b');
const cfg = b.finish();
expect(cfg.blocks.map((bl) => bl.index)).toEqual([0, 1, 2, 3]);
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit');
});
});
describe('ControlFlowContext', () => {
it('plain break/continue resolve to the nearest loop', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(/*continueTo*/ 10, /*breakTo*/ 20);
expect(ctx.continueTarget()).toBe(10);
expect(ctx.breakTarget()).toBe(20);
ctx.pop();
expect(ctx.breakTarget()).toBeUndefined();
});
it('break resolves to the nearest switch; continue skips switches to the loop', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(100, 200); // outer loop
ctx.pushSwitch(30); // inner switch
expect(ctx.breakTarget()).toBe(30); // break → switch
expect(ctx.continueTarget()).toBe(100); // continue skips switch → loop
ctx.pop();
expect(ctx.breakTarget()).toBe(200);
});
it('labeled break/continue resolve to the labeled loop, not the nearest', () => {
const ctx = new ControlFlowContext();
ctx.pushLoop(/*outer*/ 100, 200, 'outer');
ctx.pushLoop(/*inner*/ 110, 210);
expect(ctx.breakTarget('outer')).toBe(200);
expect(ctx.continueTarget('outer')).toBe(100);
expect(ctx.breakTarget()).toBe(210); // unlabeled → nearest (inner)
});
});