mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-04 02:31:36 +00:00
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:
parent
f2c9e69792
commit
6e0c1e1e7f
5 changed files with 389 additions and 0 deletions
114
gitnexus/src/core/ingestion/cfg/cfg-builder.ts
Normal file
114
gitnexus/src/core/ingestion/cfg/cfg-builder.ts
Normal 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;
|
||||
};
|
||||
70
gitnexus/src/core/ingestion/cfg/control-flow-context.ts
Normal file
70
gitnexus/src/core/ingestion/cfg/control-flow-context.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
21
gitnexus/src/core/ingestion/cfg/traversal-result.ts
Normal file
21
gitnexus/src/core/ingestion/cfg/traversal-result.ts
Normal 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] });
|
||||
64
gitnexus/src/core/ingestion/cfg/types.ts
Normal file
64
gitnexus/src/core/ingestion/cfg/types.ts
Normal 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;
|
||||
}
|
||||
120
gitnexus/test/unit/cfg/cfg-builder.test.ts
Normal file
120
gitnexus/test/unit/cfg/cfg-builder.test.ts
Normal 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)
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue