test(cfg): language-agnostic CFG unit-test harness (#2195 U1)

Extract the grammar-agnostic engine from ts-cfg-harness into
makeCfgHarness(grammar, visitor, filePath) at test/helpers/cfg-harness.ts.
Function discovery delegates to visitor.isFunction, so the harness carries
no language-specific node-type knowledge -- each C-family visitor's unit
tests can drive the real worker-side builder against real source.

ts-cfg-harness becomes a thin TS binding re-exporting the same
parse/collectFunctions/cfgOf/cfgsOf (behavior-preserving: all 5 existing
consumers -- taint propagate/model-match/summary-harvest/taint-emit + cfg
harvest -- pass unchanged, 223 tests green). New harness.test.ts proves
TS-faithfulness and isFunction-delegation via a stub visitor.

The bench parameterization (measure.mjs) is sequenced into U7, where the
first C-family scaling scenario makes the {grammar, visitorFactory} seam
validatable against a real non-TS language.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 10:43:00 +00:00
parent 5439c363de
commit bc7cc3ffc8
3 changed files with 137 additions and 45 deletions

View file

@ -0,0 +1,69 @@
/**
* Language-agnostic CFG unit-test harness (#2195 U1).
*
* Generalizes the TS-bound `ts-cfg-harness` into a factory parameterized by a
* tree-sitter grammar + a {@link CfgVisitor}, so each language's CFG visitor
* unit tests can drive the real worker-side builder against real source —
* never hand-built mocks. Function discovery delegates to `visitor.isFunction`,
* so this helper carries NO language-specific node-type knowledge (the
* no-language-naming rule the shared ingestion core follows).
*/
import Parser from 'tree-sitter';
import type { SyntaxNode } from '../../src/core/ingestion/utils/ast-helpers.js';
import type { CfgVisitor, FunctionCfg } from '../../src/core/ingestion/cfg/types.js';
export interface CfgHarness {
/** Parse `code` with the configured grammar; returns the root node. */
parse(code: string): SyntaxNode;
/** Every function node under `root` (pre-order), per `visitor.isFunction`. */
collectFunctions(root: SyntaxNode): SyntaxNode[];
/** CFG of the function at `index` (default 0). Throws if absent/undefined. */
cfgOf(code: string, index?: number): FunctionCfg;
/** Every function's CFG, in source order. */
cfgsOf(code: string): FunctionCfg[];
}
/**
* Build a CFG harness for one grammar + visitor. `filePath` is the synthetic
* path threaded into `buildFunctionCfg` (it only affects BasicBlock ids, not
* CFG shape). The parser is created once and reused across parses.
*/
export function makeCfgHarness(
grammar: Parser.Language,
visitor: CfgVisitor<SyntaxNode>,
filePath = 'fixture',
): CfgHarness {
const parser = new Parser();
parser.setLanguage(grammar);
const parse = (code: string): SyntaxNode => parser.parse(code).rootNode;
const collectFunctions = (root: SyntaxNode): SyntaxNode[] => {
const out: SyntaxNode[] = [];
const stack: SyntaxNode[] = [root];
while (stack.length) {
const n = stack.pop() as SyntaxNode;
if (visitor.isFunction(n)) out.push(n);
for (let i = n.namedChildCount - 1; i >= 0; i--) {
const c = n.namedChild(i);
if (c) stack.push(c);
}
}
return out;
};
const cfgOf = (code: string, index = 0): FunctionCfg => {
const fn = collectFunctions(parse(code))[index];
if (!fn) throw new Error(`no function at index ${index}`);
const cfg = visitor.buildFunctionCfg(fn, filePath);
if (!cfg) throw new Error('buildFunctionCfg returned undefined');
return cfg;
};
const cfgsOf = (code: string): FunctionCfg[] =>
collectFunctions(parse(code))
.map((fn) => visitor.buildFunctionCfg(fn, filePath))
.filter((c): c is FunctionCfg => c !== undefined);
return { parse, collectFunctions, cfgOf, cfgsOf };
}

View file

@ -6,58 +6,27 @@
* structures the pipeline feeds `computeReachingDefs` / `matchFunctionSites` /
* `computeTaintFlows`, never hand-built mocks. Extracted from the byte-identical
* copies that lived in model-match / propagate / taint-emit / harvest tests.
*
* The grammar-agnostic engine now lives in {@link makeCfgHarness} (#2195 U1);
* this module is the thin TS binding that preserves the original exports.
*/
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import type { ParsedImport } from 'gitnexus-shared';
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';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.js';
import { emitTsScopeCaptures } from '../../src/core/ingestion/languages/typescript/captures.js';
import { interpretTsImport } from '../../src/core/ingestion/languages/typescript/interpret.js';
import { makeCfgHarness } from './cfg-harness.js';
const visitor = createTypeScriptCfgVisitor();
const harness = makeCfgHarness(
TypeScript.typescript,
createTypeScriptCfgVisitor(),
'fixture.ts',
);
export function parse(code: string): SyntaxNode {
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
return parser.parse(code).rootNode;
}
export 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;
}
/** The CFG of the function at `index` (default 0). */
export 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;
}
/** Every function's CFG, in source order. */
export function cfgsOf(code: string): FunctionCfg[] {
return collectFunctions(parse(code))
.map((fn) => visitor.buildFunctionCfg(fn, 'fixture.ts'))
.filter((c): c is FunctionCfg => c !== undefined);
}
export const parse = harness.parse;
export const collectFunctions = harness.collectFunctions;
export const cfgOf = harness.cfgOf;
export const cfgsOf = harness.cfgsOf;
/** Real ParsedImports via the TS scope-capture + interpreter path. */
export function importsFor(src: string): ParsedImport[] {

View file

@ -0,0 +1,54 @@
import { describe, it, expect } from 'vitest';
import TypeScript from 'tree-sitter-typescript';
import { createTypeScriptCfgVisitor } from '../../../src/core/ingestion/cfg/visitors/typescript.js';
import type { CfgVisitor, FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import type { SyntaxNode } from '../../../src/core/ingestion/utils/ast-helpers.js';
import { makeCfgHarness } from '../../helpers/cfg-harness.js';
import { cfgOf as tsBoundCfgOf } from '../../helpers/ts-cfg-harness.js';
describe('makeCfgHarness — language-parameterized CFG harness (#2195 U1)', () => {
it('reproduces the TS-bound harness CFG when driven with the TS grammar+visitor', () => {
const code = 'function f(x: number) { if (x > 0) { a(); } else { b(); } return x; }';
const generic = makeCfgHarness(
TypeScript.typescript,
createTypeScriptCfgVisitor(),
'fixture.ts',
).cfgOf(code);
// The thin TS binding (ts-cfg-harness) must be a pure delegate of the
// generalized engine: same grammar + visitor + filePath ⇒ identical CFG.
expect(generic).toEqual(tsBoundCfgOf(code));
});
it('delegates function discovery to visitor.isFunction (no hardcoded TS node set)', () => {
// Stub visitor: recognizes ONLY arrow_function and emits a sentinel 1-block
// CFG. If the harness carried TS-specific function detection it would
// over-collect (function_declaration, method_definition, …); delegating to
// isFunction means it finds exactly the arrows — proving zero TS coupling.
const built: string[] = [];
const stub: CfgVisitor<SyntaxNode> = {
isFunction: (n) => n.type === 'arrow_function',
buildFunctionCfg: (fn, filePath): FunctionCfg => {
built.push(fn.type);
return {
filePath,
functionStartLine: fn.startPosition.row + 1,
functionEndLine: fn.endPosition.row + 1,
functionStartColumn: fn.startPosition.column,
entryIndex: 0,
exitIndex: 1,
blocks: [
{ index: 0, startLine: 1, endLine: 1, text: '', kind: 'entry' },
{ index: 1, startLine: 1, endLine: 1, text: '', kind: 'exit' },
],
edges: [{ from: 0, to: 1, kind: 'seq' }],
};
},
};
const code = ['function fd() { return 1; }', 'const a = () => 2;', 'const b = (x) => x + 1;'].join(
'\n',
);
const cfgs = makeCfgHarness(TypeScript.typescript, stub, 'stub.ts').cfgsOf(code);
expect(cfgs).toHaveLength(2); // the two arrows; the function_declaration is ignored
expect(built).toEqual(['arrow_function', 'arrow_function']);
});
});