diff --git a/gitnexus/bench/cfg/baselines.json b/gitnexus/bench/cfg/baselines.json index 0917b6bdd..606ccd19e 100644 --- a/gitnexus/bench/cfg/baselines.json +++ b/gitnexus/bench/cfg/baselines.json @@ -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." } } diff --git a/gitnexus/bench/cfg/measure.mjs b/gitnexus/bench/cfg/measure.mjs index a4a2c9eaa..4fe6bd267 100644 --- a/gitnexus/bench/cfg/measure.mjs +++ b/gitnexus/bench/cfg/measure.mjs @@ -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()` 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), }; } diff --git a/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap new file mode 100644 index 000000000..06e4b033e --- /dev/null +++ b/gitnexus/test/integration/cfg/__snapshots__/pipeline-pdg.test.ts.snap @@ -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, +} +`; diff --git a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts index 2177c761a..c1b266733 100644 --- a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts +++ b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts @@ -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; + byRelType: Record; + edgeDigest: string; +} { + const byType: Record = {}; + const byRelType: Record = {}; + const nodeKey = new Map(); + result.graph.forEachNode((n) => { + byType[n.label] = (byType[n.label] ?? 0) + 1; + const props = n.properties as Record; + 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): Record => { + const out: Record = {}; + 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(); + 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); + } +});