From bc7cc3ffc81525af89888755ac07eaeb8c243036 Mon Sep 17 00:00:00 2001 From: Gergo Magyar Date: Sun, 14 Jun 2026 10:43:00 +0000 Subject: [PATCH] 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) --- gitnexus/test/helpers/cfg-harness.ts | 69 +++++++++++++++++++++++++ gitnexus/test/helpers/ts-cfg-harness.ts | 59 +++++---------------- gitnexus/test/unit/cfg/harness.test.ts | 54 +++++++++++++++++++ 3 files changed, 137 insertions(+), 45 deletions(-) create mode 100644 gitnexus/test/helpers/cfg-harness.ts create mode 100644 gitnexus/test/unit/cfg/harness.test.ts diff --git a/gitnexus/test/helpers/cfg-harness.ts b/gitnexus/test/helpers/cfg-harness.ts new file mode 100644 index 000000000..2c907d609 --- /dev/null +++ b/gitnexus/test/helpers/cfg-harness.ts @@ -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, + 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 }; +} diff --git a/gitnexus/test/helpers/ts-cfg-harness.ts b/gitnexus/test/helpers/ts-cfg-harness.ts index 12f8509c6..1d683ef82 100644 --- a/gitnexus/test/helpers/ts-cfg-harness.ts +++ b/gitnexus/test/helpers/ts-cfg-harness.ts @@ -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[] { diff --git a/gitnexus/test/unit/cfg/harness.test.ts b/gitnexus/test/unit/cfg/harness.test.ts new file mode 100644 index 000000000..00dea42b3 --- /dev/null +++ b/gitnexus/test/unit/cfg/harness.test.ts @@ -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 = { + 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']); + }); +});