mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
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:
parent
b294a387a5
commit
abcae87cbd
4 changed files with 371 additions and 30 deletions
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
`;
|
||||
|
|
@ -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);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue