test(cfg): worker-mode PDG integration + bench parameterization (#2195 U7)

Prove the five C-family visitors build PDG through the REAL worker
pipeline. pipeline-pdg.test.ts: per-language (C/C++/C#/Java/Go) temp repo
run with pdg:true asserts BasicBlock+CFG+REACHING_DEF+CDG all > 0 (CDG>0
proves EXIT stays reverse-reachable end-to-end through the worker, incl.
each fixture's non-terminating loop/select); a paired run with pdg off
asserts == 0, the two flag-off graphs byte-identical (R3), no PDG types
leak, pinned by a golden snapshot. Counts e.g. Go 151 BB / 56 CDG.

Parameterize bench/cfg/measure.mjs by a per-language LANGS registry
resolved generically via getLanguageGrammar + getProvider(X).cfgVisitor
(no static import table). Default TS byte-identical -- all 6 TS
fingerprints unchanged under --check; taint-dense stays TS-only
(TS_JS_TAINT_MODEL never runs against model-less C-family CFGs). Add a
go:branchy scenario+baseline (namespaced) -- its fingerprint shape
(32 blocks/46 edges) matches TS branchy, cross-validating the Go visitor.

15 pipeline tests + bench --check PASS (7 scenarios); 354 unit cfg green;
dist rebuilt clean. Absorbs the bench parameterization deferred from U1.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 12:15:08 +00:00
parent b294a387a5
commit abcae87cbd
4 changed files with 371 additions and 30 deletions

View file

@ -53,5 +53,13 @@
"taint_reason_bytes_large_max": 198000,
"taint_zero_match_budget": 0.5,
"_note": "#2083 M3 U7 (R10): N functions, each with 12 req.body sources + a 4-hop chain + 13 eval sinks (13 deduped findings/fn) at 125->500 fns; the zero-match control (inp.payload/evalish) keeps the identical CFG shape with zero model hits. BOUNDEDNESS pin: kept findings/function == 8 (the scenario cap) at BOTH sizes -- above means the cap was lost, below means detection regressed; total findings grow linearly with N by design. disk_bytes_large_max is the LOAD-BEARING site-harvest absolute ceiling (densest sites of the suite; measured 2335772 at N=500, ceiling ~1.35x). taint_reason_bytes_large_max caps the persisted TAINTED reason bytes (measured 146827 = ~37 B/finding, ceiling ~1.35x; blows on hop-encoding bloat or cap loss). taint_zero_match_budget 0.5 vs measured 0.15: the zero-match pass (match gate only, no solver) must stay a small fraction of the match-dense pass. taint scaling measured ~0.93 (per-function work is N-linear); time/disk/heap/rd ratios all ~1.0."
},
"go:branchy": {
"fingerprint": "bba6ad5452c64125daa1dec4cf25e5e111692748a4ef30f309c9b0e03b3e5017",
"scaling_budget": 1.8,
"disk_bytes_budget": 1.2,
"heap_budget": 1.3,
"rd_scaling_budget": 2.0,
"_note": "#2195 U7: the first NON-TS scaling scenario -- the C-family analogue of `branchy`, driven through the Go grammar + Go CFG visitor (lang:'go'). ONE Go function with N sequential `if`s (block/edge growth in a single CFG). The `go:` key namespace keeps it out of the TS baseline keyspace (no collision/re-baseline of a TS scenario). Measured time ~1.08, disk ~1.03, heap ~1.0, rd ~1.06 (budgets mirror the TS `branchy` scenario: scaling 1.8 absorbs single-CFG noise + catches a ~4.0 quadratic). Cross-check: fp_blocks 32 / fp_edges 46 are IDENTICAL to the TS branchy fingerprint shape -- the Go visitor builds the same per-`if` block/edge topology. CFG-only (Go has no registered taint model), so no taint gates. Re-baseline the fingerprint only on an intentional Go CFG/harvest-shape change."
}
}

View file

@ -42,12 +42,13 @@ import crypto from 'node:crypto';
import { fileURLToPath } from 'node:url';
import Parser from 'tree-sitter';
import TypeScript from 'tree-sitter-typescript';
import { collectFunctionCfgs } from '../../src/core/ingestion/cfg/collect.ts';
import { computeReachingDefs } from '../../src/core/ingestion/cfg/reaching-defs.ts';
import { DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION } from '../../src/core/ingestion/cfg/emit.ts';
import { createTypeScriptCfgVisitor } from '../../src/core/ingestion/cfg/visitors/typescript.ts';
import { getTreeSitterBufferSize } from '../../src/core/ingestion/constants.ts';
import { getLanguageGrammar } from '../../src/core/tree-sitter/parser-loader.ts';
import { getProvider } from '../../src/core/ingestion/languages/index.ts';
import { SupportedLanguages } from '../../src/config/supported-languages.ts';
import { buildTaintImportIndex, matchFunctionSites } from '../../src/core/ingestion/taint/match.ts';
import { TS_JS_TAINT_MODEL } from '../../src/core/ingestion/taint/typescript-model.ts';
import {
@ -59,12 +60,53 @@ import { encodeTaintPath } from '../../src/core/ingestion/taint/path-codec.ts';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const BASELINE_PATH = path.resolve(__dirname, 'baselines.json');
const visitor = createTypeScriptCfgVisitor();
const parser = new Parser();
parser.setLanguage(TypeScript.typescript);
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
// ---- per-language registry (the U1 parameterization, #2195) ----
//
// A scenario names a `lang` (default 'ts'); the registry resolves its grammar,
// CFG visitor, and (optional) taint model GENERICALLY — the grammar via the
// production `getLanguageGrammar` loader and the visitor via the provider's
// `cfgVisitor` hook (the same seam `cfg-snapshot.test.ts` uses). No language is
// named in the bench logic itself: adding a language is one row here, not a new
// static grammar import (the language-naming anti-pattern). Lazy by design —
// only languages actually referenced by a scenario are loaded, so a missing
// optional grammar never breaks an unrelated run.
//
// - `grammar` — SupportedLanguages enum value for `getLanguageGrammar`.
// - `taintModel` — source/sink config threaded into the taint pass. ONLY the
// TS row carries `TS_JS_TAINT_MODEL`; C-family rows have no
// model (matching prod: `getSourceSinkConfig(<c-lang>)` is
// `undefined`), so the TS model never runs against a
// C-family CFG.
const LANGS = {
ts: { grammar: SupportedLanguages.TypeScript, taintModel: TS_JS_TAINT_MODEL },
go: { grammar: SupportedLanguages.Go, taintModel: null },
java: { grammar: SupportedLanguages.Java, taintModel: null },
c: { grammar: SupportedLanguages.C, taintModel: null },
cpp: { grammar: SupportedLanguages.CPlusPlus, taintModel: null },
csharp: { grammar: SupportedLanguages.CSharp, taintModel: null },
};
// Lazily build + cache one { parser, visitor, parse, taintModel } toolkit per
// language id. The parser is created once and reused across parses/reps for that
// language (parse cost is isolated from CFG-build cost by reusing the tree).
const langToolkitCache = new Map();
function langToolkit(langId) {
const cached = langToolkitCache.get(langId);
if (cached) return cached;
const spec = LANGS[langId];
if (!spec) throw new Error(`bench: unknown lang '${langId}' (add a row to LANGS)`);
const visitor = getProvider(spec.grammar).cfgVisitor;
if (!visitor)
throw new Error(`bench: provider for '${langId}' has no cfgVisitor (visitor not wired?)`);
const parser = new Parser();
parser.setLanguage(getLanguageGrammar(spec.grammar));
// Large synthetic sources exceed tree-sitter's default read buffer; size it
// from the content exactly as the parse worker does (getTreeSitterBufferSize).
const parse = (src) => parser.parse(src, undefined, { bufferSize: getTreeSitterBufferSize(src) });
const toolkit = { visitor, parse, taintModel: spec.taintModel };
langToolkitCache.set(langId, toolkit);
return toolkit;
}
// ---- synthetic generators (one cost dimension each) ----
@ -166,10 +208,28 @@ const SCENARIOS = [
// cost ~nothing (no solver call), gated as zero-time/dense-time ratio.
small: 125,
large: 500, // 4x, like the global sizes — per-fn bodies are ~30 lines
lang: 'ts', // taint model is TS-only; never run TS_JS_TAINT_MODEL on a C-family CFG
taint: { cap: 8 },
gen: (n) => genTaintFunctions(n, false),
genZero: (n) => genTaintFunctions(n, true),
},
{
name: 'go:branchy',
// #2195 U7: the first NON-TS scaling scenario — the C-family analogue of the
// TS `branchy` stressor, run through the Go grammar + Go CFG visitor. ONE Go
// function with N sequential `if`s → N condition blocks + 2N+ edges in a
// single CFG; stresses block/edge growth and the namedChildren walk on the
// Go body. The `go:` namespace keys it out of the TS baseline keyspace so a
// C-family entry can never collide with (or silently re-baseline) a TS
// scenario. CFG-only (Go has no registered taint model — see LANGS), so the
// gated metrics are the time/disk/heap/rd scaling ratios + the fingerprint.
lang: 'go',
gen: (n) => {
let s = 'package p\nfunc f(x int) {\n';
for (let i = 0; i < n; i++) s += `\tif x > ${i} {\n\t\ts${i}()\n\t}\n`;
return s + '}\n';
},
},
];
// taint-dense generator: `zero` swaps every model-matched name for an
@ -207,14 +267,14 @@ function median(xs) {
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function measureCollect(src, file, reps) {
const root = parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
function measureCollect(tk, src, file, reps) {
const root = tk.parse(src).rootNode; // parse ONCE; reuse across reps
collectFunctionCfgs(root, tk.visitor, `warmup-${file}`, NO_CAP); // warm JIT (uncounted)
const samples = [];
let out;
for (let i = 0; i < reps; i++) {
const start = process.hrtime.bigint();
out = collectFunctionCfgs(root, visitor, file, NO_CAP);
out = collectFunctionCfgs(root, tk.visitor, file, NO_CAP);
samples.push(Number(process.hrtime.bigint() - start) / 1e6);
}
return {
@ -257,7 +317,7 @@ function measureReachingDefs(cfgs, reps, maxFacts) {
// maxFindingsPerFunction (deliberately small so the cap BINDS on the dense
// generator). Also sums the encoded TAINTED `reason` bytes for the kept
// findings — the persisted-taint disk posture (R10).
function measureTaint(cfgs, reps, cap) {
function measureTaint(cfgs, reps, cap, taintModel) {
const importIndex = buildTaintImportIndex([]); // bench callees are globals
const pass = () => {
let analyzed = 0;
@ -265,7 +325,7 @@ function measureTaint(cfgs, reps, cap) {
let dropped = 0;
let reasonBytes = 0;
for (const c of cfgs) {
const matches = matchFunctionSites(c, TS_JS_TAINT_MODEL, importIndex);
const matches = matchFunctionSites(c, taintModel, importIndex);
if (!matches.hasSource || !matches.hasSink) continue;
const du = computeReachingDefs(c, {
maxFacts: DEFAULT_PDG_MAX_REACHING_DEF_FACTS_PER_FUNCTION,
@ -307,7 +367,7 @@ function measureTaint(cfgs, reps, cap) {
// run without the flag still works).
const GC = typeof global.gc === 'function' ? () => (global.gc(), global.gc()) : null;
function retainedHeapBytes(src, file) {
function retainedHeapBytes(tk, src, file) {
if (!GC) return null;
// Retained-size-by-RELEASE: measure the heap with the CFGs held, drop them,
// GC, measure again. The drop isolates exactly the JS heap the cfgSideChannel
@ -315,7 +375,7 @@ function retainedHeapBytes(src, file) {
// is flushed) — robust to pre-existing garbage, which is constant across both
// measurements. The parse tree is a temporary (its native memory isn't on the
// JS heap); block text strings are fresh copies, so they count here.
let cfgs = collectFunctionCfgs(parse(src).rootNode, visitor, file, NO_CAP).cfgs;
let cfgs = collectFunctionCfgs(tk.parse(src).rootNode, tk.visitor, file, NO_CAP).cfgs;
GC();
const withCfgs = process.memoryUsage().heapUsed;
if (cfgs.length < 0) throw new Error('unreachable'); // keep cfgs live past withCfgs
@ -342,8 +402,8 @@ function canonicalizeCfg(cfg) {
return `${cfg.functionStartLine}:${cfg.functionStartColumn}\n${bindings}\n${blocks.join('\n')}\n${edges.join('\n')}`;
}
function fingerprint(scenario) {
const out = collectFunctionCfgs(parse(scenario.gen(FP_SIZE)).rootNode, visitor, 'fp.ts', NO_CAP);
function fingerprint(tk, scenario) {
const out = collectFunctionCfgs(tk.parse(scenario.gen(FP_SIZE)).rootNode, tk.visitor, 'fp', NO_CAP);
const canon = out.cfgs.map(canonicalizeCfg).sort().join('\n====\n');
return {
fingerprint: crypto.createHash('sha256').update(canon).digest('hex'),
@ -354,19 +414,23 @@ function fingerprint(scenario) {
}
function measureScenario(scenario) {
// Resolve the scenario's language toolkit ONCE (default 'ts' keeps every
// pre-existing TS scenario on the exact same grammar+visitor+model path it
// used before the U1 parameterization → byte-identical baselines).
const tk = langToolkit(scenario.lang ?? 'ts');
// Per-scenario sizes (straight-line needs larger N to separate a concat
// quadratic from noise — see its comment); the rest default to the globals.
const nSmall = scenario.small ?? SMALL;
const nLarge = scenario.large ?? LARGE;
const small = measureCollect(scenario.gen(nSmall), `${scenario.name}.ts`, REPS);
const large = measureCollect(scenario.gen(nLarge), `${scenario.name}.ts`, REPS);
const small = measureCollect(tk, scenario.gen(nSmall), `${scenario.name}.src`, REPS);
const large = measureCollect(tk, scenario.gen(nLarge), `${scenario.name}.src`, REPS);
const sizeRatio = nLarge / nSmall;
const scalingRatio = small.ms > 0 ? large.ms / small.ms / sizeRatio : 0;
const diskRatio = small.diskBytes > 0 ? large.diskBytes / small.diskBytes / sizeRatio : 0;
// Memory growth (only when --expose-gc gave us a forced GC).
const heapSmall = retainedHeapBytes(scenario.gen(nSmall), `${scenario.name}.ts`);
const heapLarge = retainedHeapBytes(scenario.gen(nLarge), `${scenario.name}.ts`);
const heapSmall = retainedHeapBytes(tk, scenario.gen(nSmall), `${scenario.name}.src`);
const heapLarge = retainedHeapBytes(tk, scenario.gen(nLarge), `${scenario.name}.src`);
const heapRatio =
heapSmall !== null && heapLarge !== null && heapSmall > 0
? heapLarge / heapSmall / sizeRatio
@ -380,22 +444,28 @@ function measureScenario(scenario) {
// ratio 0 and the gate would self-disable exactly when the solver is fast.
const rdRatio = rdLarge.ms / Math.max(rdSmall.ms, 0.001) / sizeRatio;
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios.
// #2083 M3 U7: taint pass cost + boundedness on taint-bearing scenarios. The
// taint model is the scenario's language model (TS_JS_TAINT_MODEL for the TS
// taint-dense scenario; a taint scenario requires a model-bearing language).
let taintMetrics = {};
if (scenario.taint !== undefined) {
if (!tk.taintModel)
throw new Error(
`bench: scenario '${scenario.name}' has a taint config but lang '${scenario.lang ?? 'ts'}' has no taint model`,
);
const cap = scenario.taint.cap;
const tSmall = measureTaint(small.cfgs, REPS, cap);
const tLarge = measureTaint(large.cfgs, REPS, cap);
const tSmall = measureTaint(small.cfgs, REPS, cap, tk.taintModel);
const tLarge = measureTaint(large.cfgs, REPS, cap, tk.taintModel);
const tRatio = tLarge.ms / Math.max(tSmall.ms, 0.001) / sizeRatio;
// Zero-match control: identical CFG shape, no model hits — measures the
// match-gate overhead unmatched functions pay on a real --pdg repo.
const zeroCfgs = collectFunctionCfgs(
parse(scenario.genZero(nLarge)).rootNode,
visitor,
`${scenario.name}-zero.ts`,
tk.parse(scenario.genZero(nLarge)).rootNode,
tk.visitor,
`${scenario.name}-zero.src`,
NO_CAP,
).cfgs;
const tZero = measureTaint(zeroCfgs, REPS, cap);
const tZero = measureTaint(zeroCfgs, REPS, cap, tk.taintModel);
taintMetrics = {
taint_ms_small: Number(tSmall.ms.toFixed(3)),
taint_ms_large: Number(tLarge.ms.toFixed(3)),
@ -431,7 +501,7 @@ function measureScenario(scenario) {
rd_scaling_ratio: Number(rdRatio.toFixed(3)),
facts_small: rdSmall.facts,
facts_large: rdLarge.facts,
...fingerprint(scenario),
...fingerprint(tk, scenario),
};
}

View file

@ -0,0 +1,93 @@
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
exports[`U7 — C-family worker-mode --pdg pipeline > C#: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest) 1`] = `
{
"byRelType": {
"CALLS": 7,
"DEFINES": 3,
"HAS_METHOD": 16,
"MEMBER_OF": 9,
"STEP_IN_PROCESS": 3,
},
"byType": {
"Class": 2,
"Community": 3,
"File": 1,
"Function": 1,
"Method": 15,
"Namespace": 1,
"Process": 1,
},
"edgeDigest": "2271af66531e4fb54afb2d6e0a024abc536e2109f445e0ecff9868c01661ebfa",
"relationships": 38,
"symbols": 24,
}
`;
exports[`U7 — C-family worker-mode --pdg pipeline > C++: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest) 1`] = `
{
"byRelType": {
"DEFINES": 6,
},
"byType": {
"File": 1,
"Function": 6,
},
"edgeDigest": "4e8cfcfe7cbde0d0a858e2f5db8af82713fbb42527d23e6fabf704382d8088df",
"relationships": 6,
"symbols": 7,
}
`;
exports[`U7 — C-family worker-mode --pdg pipeline > C: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest) 1`] = `
{
"byRelType": {
"DEFINES": 9,
},
"byType": {
"File": 1,
"Function": 9,
},
"edgeDigest": "887c0c3f91a858df6333d2105f03160b3232ff625f6dc04328425a0bbbd58cd3",
"relationships": 9,
"symbols": 10,
}
`;
exports[`U7 — C-family worker-mode --pdg pipeline > Go: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest) 1`] = `
{
"byRelType": {
"CALLS": 14,
"DEFINES": 28,
"MEMBER_OF": 22,
},
"byType": {
"Community": 8,
"File": 1,
"Function": 28,
},
"edgeDigest": "731ee1aa406fe7a96c5747dc2e5059f9079fd883dfca70a1933d49ce7f161a99",
"relationships": 64,
"symbols": 37,
}
`;
exports[`U7 — C-family worker-mode --pdg pipeline > Java: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest) 1`] = `
{
"byRelType": {
"CALLS": 10,
"DEFINES": 1,
"HAS_METHOD": 21,
"MEMBER_OF": 14,
},
"byType": {
"Class": 1,
"Community": 4,
"File": 1,
"Method": 21,
},
"edgeDigest": "b5e4c1459c59f385949964c3e4e479e67c98a2eaa7e102f4acacb108a9ccec29",
"relationships": 46,
"symbols": 27,
}
`;

View file

@ -2,6 +2,7 @@ import { describe, it, expect, afterAll } from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';
import crypto from 'crypto';
import { runPipelineFromRepo } from '../../../src/core/ingestion/pipeline.js';
import type { PipelineResult } from '../../../src/types/pipeline.js';
import { decodeTaintPath } from '../../../src/core/ingestion/taint/path-codec.js';
@ -187,3 +188,172 @@ describe('U7 — end-to-end --pdg pipeline', () => {
expect(cdg).toBe(0);
}, 60000);
});
// ── C-family worker-mode PDG (#2195 U7) ─────────────────────────────────────
//
// The same both-sinks proof as the TS block above, run through the REAL worker
// pipeline for each of C, C++, C#, Java, Go. Each language gets its own tiny
// repo (one hazard fixture with real branching AND a non-terminating
// loop/`select`) and we assert, under `--pdg`:
// - BasicBlock + CFG > 0 (the worker built a per-function CFG and emit wired it)
// - REACHING_DEF + CDG > 0 (the def/use harvest + the post-dom/CDG passes
// populate — CDG > 0 proves EXIT stays reverse-reachable end-to-end through
// the worker even with the non-terminating loop, not just in unit probes)
// and without `--pdg` (both the default run and an explicit `pdg:false` run):
// - BasicBlock + CFG + REACHING_DEF + CDG == 0
// - the non-PDG graph is byte-identical between the two flag-off runs and
// matches a committed digest snapshot (the per-language byte-identical-off
// golden parity gate — R3; the cross-repo gate is pipeline-graph-golden).
//
// ⚠ Requires a FRESH `dist/parse-worker.js` — CFGs are built in the worker from
// `dist/`. A stale bundle silently zeros CFG output. `pretest:integration` (and
// the U7 verification recipe) run `node scripts/build.js` first.
const C_FAMILY_FIXTURES = path.join(__dirname, 'fixtures');
const C_FAMILY: ReadonlyArray<{ lang: string; fixture: string }> = [
{ lang: 'C', fixture: 'c-hazards.c' },
{ lang: 'C++', fixture: 'cpp-hazards.cpp' },
{ lang: 'C#', fixture: 'csharp-hazards.cs' },
{ lang: 'Java', fixture: 'java-hazards.java' },
{ lang: 'Go', fixture: 'go-hazards.go' },
];
const cFamilyTmpDirs: string[] = [];
function freshLangRepo(fixture: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-lang-'));
fs.copyFileSync(path.join(C_FAMILY_FIXTURES, fixture), path.join(dir, fixture));
cFamilyTmpDirs.push(dir);
return dir;
}
// Force worker-pool parsing even for a single small file: the CFG is built IN
// the worker, so the proof is only meaningful on the worker path.
const WORKER_PDG = {
pdg: true,
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
} as const;
const WORKER_OFF = {
workerThresholdsForTest: { minFiles: 1, minBytes: 1 },
workerPoolSize: 2,
} as const;
/**
* Deterministic, path/id-independent digest of the non-PDG graph: sorted
* label→count and relType→count maps + a sha256 over symbolic edge triples
* (label:name keyed, not opaque ids). Mirrors `pipeline-graph-golden`'s
* technique so the snapshot is stable across id-format refactors and only
* trips on a real semantic change to the C-family graph.
*/
function graphDigest(result: PipelineResult): {
symbols: number;
relationships: number;
byType: Record<string, number>;
byRelType: Record<string, number>;
edgeDigest: string;
} {
const byType: Record<string, number> = {};
const byRelType: Record<string, number> = {};
const nodeKey = new Map<string, string>();
result.graph.forEachNode((n) => {
byType[n.label] = (byType[n.label] ?? 0) + 1;
const props = n.properties as Record<string, unknown>;
const fp = (props.filePath as string | undefined) ?? '';
const nm = (props.name as string | undefined) ?? '';
nodeKey.set(n.id, `${n.label}:${nm}@${fp}`);
});
const triples: string[] = [];
for (const rel of result.graph.iterRelationships()) {
byRelType[rel.type] = (byRelType[rel.type] ?? 0) + 1;
const src = nodeKey.get(rel.sourceId) ?? `?:${rel.sourceId}`;
const dst = nodeKey.get(rel.targetId) ?? `?:${rel.targetId}`;
triples.push(`${rel.type}|${src}|${dst}`);
}
triples.sort();
const sortObj = (o: Record<string, number>): Record<string, number> => {
const out: Record<string, number> = {};
for (const k of Object.keys(o).sort()) out[k] = o[k];
return out;
};
return {
symbols: result.graph.nodeCount,
relationships: result.graph.relationshipCount,
byType: sortObj(byType),
byRelType: sortObj(byRelType),
edgeDigest: crypto.createHash('sha256').update(triples.join('\n')).digest('hex'),
};
}
describe('U7 — C-family worker-mode --pdg pipeline', () => {
afterAll(() => {
for (const d of cFamilyTmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
for (const { lang, fixture } of C_FAMILY) {
it(`${lang}: --pdg on emits BasicBlock + CFG + REACHING_DEF + CDG (> 0) via the worker`, async () => {
const result = await runPipelineFromRepo(freshLangRepo(fixture), () => {}, WORKER_PDG);
// The CFG is built in the worker — a stale dist silently zeros this.
expect(result.usedWorkerPool).toBe(true);
const { basicBlocks, cfgEdges, reachingDefs, cdg } = counts(result);
expect(basicBlocks, `${lang} BasicBlock count`).toBeGreaterThan(0);
expect(cfgEdges, `${lang} CFG edge count`).toBeGreaterThan(0);
// def/use harvest populated the data-dependence layer.
expect(reachingDefs, `${lang} REACHING_DEF count`).toBeGreaterThan(0);
// CDG > 0 proves the post-dom/CDG pass was NOT skipped — i.e. EXIT stays
// reverse-reachable end-to-end through the worker, including from the
// fixture's non-terminating loop/`select` (the silent-zero hazard).
expect(cdg, `${lang} CDG count`).toBeGreaterThan(0);
// Both CFG and CDG endpoints are persisted BasicBlocks; CDG carries a T/F.
const blockIds = new Set<string>();
result.graph.forEachNode((n) => {
if (n.label === 'BasicBlock') blockIds.add(n.id);
});
for (const rel of result.graph.iterRelationships()) {
if (rel.type === 'CFG' || rel.type === 'REACHING_DEF' || rel.type === 'CDG') {
expect(blockIds.has(rel.sourceId), `${lang} ${rel.type} source is a BasicBlock`).toBe(
true,
);
expect(blockIds.has(rel.targetId), `${lang} ${rel.type} target is a BasicBlock`).toBe(
true,
);
}
if (rel.type === 'CDG') expect(['T', 'F']).toContain(rel.reason);
}
}, 60000);
it(`${lang}: --pdg off is byte-identical (zero PDG nodes/edges, stable golden digest)`, async () => {
// Default (no pdg flag) and explicit pdg:false must produce the IDENTICAL
// graph — the R3 parity property — and neither carries any PDG layer.
const defaultRun = await runPipelineFromRepo(freshLangRepo(fixture), () => {}, WORKER_OFF);
const offRun = await runPipelineFromRepo(freshLangRepo(fixture), () => {}, {
...WORKER_OFF,
pdg: false,
});
for (const r of [defaultRun, offRun]) {
const { basicBlocks, cfgEdges, reachingDefs, tainted, sanitizes, cdg } = counts(r);
expect(basicBlocks).toBe(0);
expect(cfgEdges).toBe(0);
expect(reachingDefs).toBe(0);
expect(tainted).toBe(0);
expect(sanitizes).toBe(0);
expect(cdg).toBe(0);
}
const defaultDigest = graphDigest(defaultRun);
const offDigest = graphDigest(offRun);
// pdg:false ≡ pdg-absent — the dominant existing-user path is untouched.
expect(offDigest).toEqual(defaultDigest);
// None of the PDG node/rel types leak into the flag-off graph.
for (const t of ['BasicBlock'] as const)
expect(defaultDigest.byType[t]).toBeUndefined();
for (const t of ['CFG', 'REACHING_DEF', 'CDG', 'TAINTED', 'SANITIZES'] as const)
expect(defaultDigest.byRelType[t]).toBeUndefined();
// Committed golden: the flag-off graph is pinned by snapshot so a future
// refactor that silently rewires the C-family graph trips this gate.
expect(defaultDigest).toMatchSnapshot();
}, 90000);
}
});