diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 6835403db..95598e9ef 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -65,8 +65,12 @@ jobs: - 'gitnexus/src/core/parsing/**/parser.js' # Test fixtures are intentionally synthetic inputs (broken/unused # code, malformed samples) used to exercise the analyzer. CodeQL - # findings here are noise, not real bugs. + # findings here are noise, not real bugs. The second glob also + # covers fixtures nested deeper in the test tree, e.g. + # test/integration/cfg/fixtures/ (the CFG/PDG hazard inputs that + # deliberately contain use-before-init / unused-variable shapes). - '**/test/fixtures/**' + - '**/test/**/fixtures/**' - name: Perform CodeQL Analysis uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0 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..efc0661a8 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,13 @@ 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 +419,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 +449,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 +506,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/src/core/ingestion/cfg/cfg-builder.ts b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts index b6e69126b..57b95a0cb 100644 --- a/gitnexus/src/core/ingestion/cfg/cfg-builder.ts +++ b/gitnexus/src/core/ingestion/cfg/cfg-builder.ts @@ -42,10 +42,40 @@ interface MutableBlock { statements: StatementFacts[]; } +/** + * Hard ceiling on CFG recursive-descent scope-entry depth (#2195). A language + * `CfgVisitor` wraps each nested block scope in {@link CfgBuilder.withNesting} (its + * `visitBody` / `visitSeq` choke points), so the live count tracks scope entries, + * not statement width. NOTE the count is ~2× LEXICAL nesting for block-bodied + * constructs (visitBody → visitSeq both enter), so the effective lexical ceiling + * is ~250 levels for block bodies (~500 for single-statement bodies / bare + * blocks). Real source nests ≤ ~50 deep, so this fires only on machine-generated + * / adversarial input. Both effective ceilings sit far below the engine's native + * stack limit (~1.2k+ nesting even on the raised worker `stackSizeMb`), so the + * bail is a DETERMINISTIC, language-independent {@link CfgNestingDepthError} + * rather than a nondeterministic `RangeError` thrown somewhere mid-walk. + */ +export const MAX_CFG_NESTING_DEPTH = 500; + +/** + * Thrown by the visitor nesting-depth guard ({@link CfgBuilder.enterNesting}) + * when lexical nesting exceeds {@link MAX_CFG_NESTING_DEPTH}. `collectFunctionCfgs` + * catches it and counts the function under `skipped.tooDeeplyNested`, isolating + * the bail to one function instead of risking a worker-wide stack overflow. + */ +export class CfgNestingDepthError extends Error { + constructor(readonly limit: number) { + super(`CFG nesting depth exceeded ${limit}`); + this.name = 'CfgNestingDepthError'; + } +} + export class CfgBuilder { private readonly blocks: MutableBlock[] = []; private readonly edges: CfgEdgeData[] = []; private readonly edgeKeys = new Set(); + /** Live recursive-descent nesting depth — see {@link enterNesting}. */ + private nesting = 0; readonly entryIndex: number; readonly exitIndex: number; @@ -119,6 +149,45 @@ export class CfgBuilder { return this.blocks.length; } + /** + * Run `fn` inside ONE nested block scope (#2195) — the single choke every + * visitor's `visitBody` / `visitSeq` funnels through. Enters on the way in and + * exits in a `finally`, so the live depth is balanced on every return AND every + * throw and the enter/exit can never drift out of pair (the reason this is one + * helper, not 24 hand-paired call sites). Throws {@link CfgNestingDepthError} + * when nesting exceeds {@link MAX_CFG_NESTING_DEPTH} — a proactive, deterministic + * bail before the native stack can overflow on a pathologically nested function. + * + * A block-bodied construct passes through BOTH visitBody and visitSeq, so it + * costs TWO scopes per lexical level: the effective structural ceiling is + * ~MAX_CFG_NESTING_DEPTH/2 (~250) lexical levels for block bodies (~500 for + * single-statement bodies / bare blocks, which hit only one of the two). Still + * an order of magnitude below the native limit and far above real code (≤ ~50). + */ + withNesting(fn: () => T): T { + this.enterNesting(); + try { + return fn(); + } finally { + this.exitNesting(); + } + } + + /** + * Increment the nesting counter, throwing {@link CfgNestingDepthError} past the + * cap. Prefer {@link withNesting}, which pairs the exit in a `finally`; this is + * exposed for direct depth-accounting tests only. + */ + enterNesting(): void { + if (++this.nesting > MAX_CFG_NESTING_DEPTH) + throw new CfgNestingDepthError(MAX_CFG_NESTING_DEPTH); + } + + /** Decrement the nesting counter — the partner of {@link enterNesting}. */ + exitNesting(): void { + this.nesting--; + } + /** Produce the serializable CFG. Caller is responsible for having wired the * function's dangling exits to {@link exitIndex} before calling. * diff --git a/gitnexus/src/core/ingestion/cfg/collect.ts b/gitnexus/src/core/ingestion/cfg/collect.ts index 890987f7d..bdf1b095d 100644 --- a/gitnexus/src/core/ingestion/cfg/collect.ts +++ b/gitnexus/src/core/ingestion/cfg/collect.ts @@ -14,6 +14,7 @@ * cannot blow up worker time/memory. A cap of `0` means no limit. */ import type { SyntaxNode } from '../utils/ast-helpers.js'; +import { CfgNestingDepthError } from './cfg-builder.js'; import type { CfgVisitor, FunctionCfg } from './types.js'; /** @@ -24,10 +25,62 @@ import type { CfgVisitor, FunctionCfg } from './types.js'; */ export const DEFAULT_PDG_MAX_FUNCTION_LINES = 2000; +/** + * CFG-bearing functions skipped during the walk, bucketed by reason (#2195). + * Surfaced per-language in the parse telemetry (parsing-processor.ts) so a CFG + * coverage gap is observable, not silent. All-zero ⇒ nothing skipped. + */ +export interface CfgSkipCounts { + /** Source span exceeded `maxFunctionLines` (minified / generated code). */ + readonly tooManyLines: number; + /** + * Recursive-descent nesting hit {@link MAX_CFG_NESTING_DEPTH} — a proactive, + * deterministic bail (see {@link CfgNestingDepthError}) before a worker stack + * overflow. + */ + readonly tooDeeplyNested: number; + /** + * `buildFunctionCfg` threw an unexpected error. Caught PER FUNCTION so one + * malformed function no longer drops the whole file's CFGs (the throw used to + * escape to the worker's language-group catch). + */ + readonly buildError: number; +} + export interface CollectedCfgs { readonly cfgs: readonly FunctionCfg[]; - /** Functions skipped for exceeding `maxFunctionLines` (0 ⇒ none skipped). */ - readonly skipped: number; + /** Per-reason skip counts (#2195). */ + readonly skipped: CfgSkipCounts; +} + +/** + * Convert a CFG built from an EXTRACTED sub-document's AST (script-relative + * tree-sitter rows) into the enclosing file's coordinates by adding `offset` to + * every source-line field. Needed for embedded scripts — a Vue SFC ` diff --git a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts index 2177c761a..00f4ed8f7 100644 --- a/gitnexus/test/integration/cfg/pipeline-pdg.test.ts +++ b/gitnexus/test/integration/cfg/pipeline-pdg.test.ts @@ -2,10 +2,13 @@ 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'; import { fixtureTaintTotals } from '../../helpers/taint-fixture.js'; +import { isLanguageAvailable } from '../../../src/core/tree-sitter/parser-loader.js'; +import { SupportedLanguages } from '../../../src/config/supported-languages.js'; // U7 — end-to-end proof that the `--pdg` opt-in reaches BOTH sinks: the parse // worker builds a per-function CFG (workerData.pdg) and scope-resolution emits @@ -187,3 +190,356 @@ 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 > 0 (the def/use harvest populates the data-dependence layer) +// - CDG > 0 AND ≥1 CDG edge is sourced INSIDE the non-terminating-loop +// function itself (`hazard`, below) — not merely an aggregate satisfied by +// any branching function in the fixture. This is the load-bearing claim: +// the post-dom/CDG pass was NOT skipped for the function whose loop traps +// EXIT, i.e. EXIT stays reverse-reachable end-to-end through the worker even +// with the non-terminating loop/`select`. (#2197 U3 — the prior whole- +// fixture `cdg > 0` aggregate did not isolate the hazard function.) +// 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'); + +// `hazard`: a substring of a BasicBlock's `text` that appears ONLY inside the +// fixture's non-terminating-loop function (`for(;;)` / `while(true)` / `for{}`). +// It locates that function's block anchor so the CDG assertion can prove the +// function specifically is CDG-bearing (see `cdgSourcedInHazardFunction`). C# +// has no such loop (its `Retry` goto-cycle is conditional and terminates), so +// it has no `hazard` and keeps the whole-fixture aggregate only. +const C_FAMILY: ReadonlyArray<{ lang: string; fixture: string; hazard?: string }> = [ + { lang: 'C', fixture: 'c-hazards.c', hazard: 'handle_request' }, // server_forever: for(;;) + { lang: 'C++', fixture: 'cpp-hazards.cpp', hazard: 'poll(' }, // run_forever: while(true) + { lang: 'C#', fixture: 'csharp-hazards.cs' }, // no non-terminating loop in the fixture + { lang: 'Java', fixture: 'java-hazards.java', hazard: 'ready(' }, // serve: while(true) + { lang: 'Go', fixture: 'go-hazards.go', hazard: 'handle(v)' }, // forInfinite: for{} +]; + +// ── Remaining-language worker-mode PDG (#2195 capstone) ───────────────────── +// +// The same both-sinks worker proof, run for the eight languages whose CFG +// visitors completed the PDG-language rollout AFTER the C-family: the dynamic +// languages (Python, PHP, Ruby), the systems/app languages (Rust, Swift, +// Kotlin, Dart), AND Vue (whose provider reuses the TypeScript CfgVisitor — the +// .vue file routes through the worker's Vue→TypeScript grammar mapping and the +// SFC +`; + const cfgs = cfgsOfSfc(sfc); + const loop = cfgs.find((c) => c.blocks.some((b) => b.text.includes('sum = sum + x'))); + expect(loop).toBeDefined(); + if (!loop) return; + + // The non-terminating loop has a back-edge but EXIT must still be reachable + // from EVERY block (the structural escape edge feeds the post-dom pass). + expect(edgeKinds(loop).has('loop-back')).toBe(true); + expect(isExitReachableFromAllBlocks(loop)).toBe(true); + + // Control dependence is computable and non-empty — the worker's CDG pass + // would emit > 0 edges for this function (matches the pipeline assertion). + const cd = computeControlDependence(loop); + expect(cd.edges.length).toBeGreaterThan(0); + for (const e of cd.edges) { + expect(['T', 'F']).toContain(e.label); + } + }); +});