diff --git a/gitnexus/src/core/ingestion/cfg/cfg-builder.ts b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts new file mode 100644 index 000000000..adf6b924f --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts @@ -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(); + 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 => { + const adj = new Map(); + 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([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; +}; diff --git a/gitnexus/src/core/ingestion/cfg/control-flow-context.ts b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts new file mode 100644 index 000000000..38c7bcbb8 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/control-flow-context.ts @@ -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; + } +} diff --git a/gitnexus/src/core/ingestion/cfg/traversal-result.ts b/gitnexus/src/core/ingestion/cfg/traversal-result.ts new file mode 100644 index 000000000..d26500fa5 --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/traversal-result.ts @@ -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] }); diff --git a/gitnexus/src/core/ingestion/cfg/types.ts b/gitnexus/src/core/ingestion/cfg/types.ts new file mode 100644 index 000000000..4a2603a3c --- /dev/null +++ b/gitnexus/src/core/ingestion/cfg/types.ts @@ -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 { + buildFunctionCfg(fnNode: TNode, filePath: string): FunctionCfg | undefined; +} diff --git a/gitnexus/test/unit/cfg/cfg-builder.test.ts b/gitnexus/test/unit/cfg/cfg-builder.test.ts new file mode 100644 index 000000000..f3738af14 --- /dev/null +++ b/gitnexus/test/unit/cfg/cfg-builder.test.ts @@ -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) + }); +});