feat(cfg): Vue (reuse TS visitor) + worker-mode proof for all langs (#2195 U15)

Vue SFC <script> blocks are extracted and parsed with the TS grammar
(parse-worker languageMap[Vue] = TypeScript.typescript), so wire
vueProvider.cfgVisitor = createTypeScriptCfgVisitor() -- pure reuse, no
Vue-specific visitor. vue-visitor.test.ts replicates the worker path
(extractVueScript -> TS parse -> CFG) and confirms branch edges + EXIT
reverse-reachable + CDG>0.

Extend pipeline-pdg.test.ts with a worker-mode block covering all eight
remaining languages (Python/PHP/Ruby/Rust/Swift/Kotlin/Dart/Vue): per-
language temp repo, real worker pool, BasicBlock+CFG+REACHING_DEF+CDG all
> 0 with --pdg (CDG>0 proves EXIT reverse-reachable end-to-end through the
worker despite each fixture's non-terminating loop), == 0 without. Counts
e.g. Ruby 122 BB/45 CDG, Vue 49 BB/11 CDG. 30 pipeline tests green.

COBOL: documented as the deliberate PDG non-goal (no grammar, exotic
PERFORM/GO-TO control flow) in cobol.ts + the worker-roundtrip gate.

This completes PDG language coverage: every supported language except
COBOL now builds CFG/REACHING_DEF/CDG under --pdg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 14:19:10 +00:00
parent 2071cadf1e
commit 417cc67a39
5 changed files with 310 additions and 0 deletions

View file

@ -34,6 +34,11 @@ export const cobolProvider = defineLanguage({
exportChecker: () => false,
importResolver: () => null,
// No `cfgVisitor`: COBOL is the deliberate non-goal of the PDG-language
// rollout (#2195). There is no installed tree-sitter grammar and COBOL's
// PERFORM / GO-TO control flow is exotic; the worker's `provider.cfgVisitor &&`
// gate therefore emits no CFG/PDG layer for COBOL (see worker-roundtrip.test.ts).
// ── Scope-resolution hooks ───────────────────────────────────────
emitScopeCaptures: emitCobolScopeCaptures,
interpretImport: interpretCobolImport,

View file

@ -37,6 +37,7 @@ import {
resolveTsImportTarget,
} from './typescript/index.js';
import { emitVueScopeCaptures } from './vue/captures.js';
import { createTypeScriptCfgVisitor } from '../cfg/visitors/typescript.js';
const VUE_SPECIFIC_BUILT_INS = [
'ref',
@ -88,6 +89,11 @@ export const vueProvider = defineLanguage({
variableExtractor: createVariableExtractor(typescriptVariableConfig),
classExtractor: vueClassExtractor,
builtInNames: VUE_BUILT_INS,
// Vue SFC <script> blocks are extracted and parsed with the TypeScript
// grammar (parse-worker GRAMMAR_BY_LANGUAGE[Vue] = TypeScript.typescript),
// so the TS CFG visitor builds CFGs for the script's functions verbatim —
// no Vue-specific visitor needed (#2195).
cfgVisitor: createTypeScriptCfgVisitor(),
// Scope-resolution pipeline hooks (RFC #909 Ring 3)
emitScopeCaptures: emitVueScopeCaptures,
interpretImport: interpretTsImport,

View file

@ -0,0 +1,83 @@
<!--
Vue SFC CFG hazard fixture (#2195 capstone). The <script setup lang="ts">
block is extracted by the SFC script extractor and parsed with the TypeScript
grammar; the Vue provider reuses the TypeScript CfgVisitor, so the worker
builds the same per-function CFG it would for a .ts file. Each function below
carries real branching, AND `eventLoop` is a non-terminating `while (true)`
with an interior `if` — the EXIT-reachability / CDG soundness hazard. The
<template> exists only to make this a realistic SFC; the CFG comes from the
script.
-->
<template>
<div class="counter" @click="onClick">{{ count }}</div>
</template>
<script setup lang="ts">
import { ref } from 'vue';
const count = ref(0);
// if / else-if / else — branch senses; rejoin at the return.
function classify(x: number): string {
let label: string;
if (x > 0) {
label = positive();
} else if (x < 0) {
label = negative();
} else {
label = zero();
}
return label;
}
// A non-terminating loop with an interior branch — keeps EXIT reverse-reachable
// only via the structural escape edge, so CDG must stay > 0 (the silent-zero
// hazard). A loop-carried accumulator (`sum`) gives the def/use harvest a fact.
function eventLoop(x: number): number {
let sum = 0;
while (true) {
if (shouldStop(x)) {
return collect(sum);
}
sum = step(sum, x);
x = advance(x);
}
}
// A second branching shape so the script has multiple CFG-bearing functions.
function onClick(): void {
if (count.value > 0) {
handle(count.value);
} else {
reset();
}
}
function positive(): string {
return 'positive';
}
function negative(): string {
return 'negative';
}
function zero(): string {
return 'zero';
}
function shouldStop(x: number): boolean {
return x > 100;
}
function collect(s: number): number {
return s;
}
function step(s: number, x: number): number {
return s + x;
}
function advance(x: number): number {
return x + 1;
}
function handle(v: number): void {
count.value = v - 1;
}
function reset(): void {
count.value = 0;
}
</script>

View file

@ -219,6 +219,33 @@ const C_FAMILY: ReadonlyArray<{ lang: string; fixture: string }> = [
{ lang: 'Go', fixture: 'go-hazards.go' },
];
// ── 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 <script setup> extractor). Each fixture carries real branching AND a
// non-terminating loop (`while True:` / `loop {}` / `while (true)`), so CDG > 0
// proves EXIT stays reverse-reachable end-to-end through the worker even with
// the silent-zero hazard. The right extension per language routes the worker to
// the correct provider/grammar (Swift/Kotlin/Dart grammars are vendored).
//
// COBOL is the deliberate non-goal of #2195 (no installed grammar; exotic
// PERFORM / GO-TO control flow). Its provider has no `cfgVisitor`, so the worker
// emits no cfgSideChannel — that gate is asserted in `worker-roundtrip.test.ts`.
const REMAINING_LANGS: ReadonlyArray<{ lang: string; fixture: string }> = [
{ lang: 'Python', fixture: 'python-hazards.py' },
{ lang: 'PHP', fixture: 'php-hazards.php' },
{ lang: 'Ruby', fixture: 'ruby-hazards.rb' },
{ lang: 'Rust', fixture: 'rust-hazards.rs' },
{ lang: 'Swift', fixture: 'swift-hazards.swift' },
{ lang: 'Kotlin', fixture: 'kotlin-hazards.kt' },
{ lang: 'Dart', fixture: 'dart-hazards.dart' },
{ lang: 'Vue', fixture: 'vue-hazards.vue' },
];
const cFamilyTmpDirs: string[] = [];
function freshLangRepo(fixture: string): string {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gn-pdg-lang-'));
@ -357,3 +384,66 @@ describe('U7 — C-family worker-mode --pdg pipeline', () => {
}, 90000);
}
});
describe('U7 — remaining languages worker-mode --pdg pipeline (#2195 capstone)', () => {
afterAll(() => {
for (const d of cFamilyTmpDirs) fs.rmSync(d, { recursive: true, force: true });
});
for (const { lang, fixture } of REMAINING_LANGS) {
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, `${lang} used the worker pool`).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 (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 emits zero PDG nodes/edges (the R3 flag-off gate)`, async () => {
// Default (no pdg flag) and explicit pdg:false must both produce a graph
// with NO PDG layer — the existing-user path stays untouched.
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);
}
// pdg:false ≡ pdg-absent — the symbolic graph digest is identical.
expect(graphDigest(offRun)).toEqual(graphDigest(defaultRun));
}, 90000);
}
});

View file

@ -0,0 +1,126 @@
import { describe, it, expect } from 'vitest';
import fs from 'fs';
import path from 'path';
import TypeScript from 'tree-sitter-typescript';
import type { FunctionCfg } from '../../../src/core/ingestion/cfg/types.js';
import { makeCfgHarness } from '../../helpers/cfg-harness.js';
import { extractVueScript } from '../../../src/core/ingestion/vue-sfc-extractor.js';
import { getProvider } from '../../../src/core/ingestion/languages/index.js';
import { SupportedLanguages } from '../../../src/config/supported-languages.js';
import { computeControlDependence } from '../../../src/core/ingestion/cfg/control-dependence.js';
import { isExitReachableFromAllBlocks } from '../../../src/core/ingestion/cfg/post-dominators.js';
// #2195 capstone — Vue reuses the TypeScript CfgVisitor (vue.ts wires
// `createTypeScriptCfgVisitor()`). This unit test replicates the worker's Vue
// path WITHOUT the worker: the SFC <script> block is extracted by
// `extractVueScript`, then parsed with the TypeScript grammar and fed through
// the Vue provider's `cfgVisitor` — exactly the worker's Vue→TypeScript grammar
// mapping. Proving the visitor reuse here means the worker-mode pipeline test
// (pipeline-pdg.test.ts) is exercising the same builder end-to-end.
const vueVisitor = getProvider(SupportedLanguages.Vue).cfgVisitor;
if (!vueVisitor) throw new Error('Vue provider has no cfgVisitor');
// The worker maps SupportedLanguages.Vue → TypeScript.typescript (parse-worker
// languageMap); mirror that grammar here so the harness parses the same way.
const harness = makeCfgHarness(TypeScript.typescript, vueVisitor, 'C.vue');
/** Extract the SFC script content the way the worker does, then CFG it. */
function cfgsOfSfc(sfc: string): FunctionCfg[] {
const extraction = extractVueScript(sfc);
if (!extraction) throw new Error('extractVueScript returned null');
return harness.cfgsOf(extraction.scriptContent);
}
const FIXTURE = path.join(__dirname, '../../integration/cfg/fixtures/vue-hazards.vue');
const block = (cfg: FunctionCfg, substr: string): number => {
const b = cfg.blocks.find((bl) => bl.text.includes(substr));
if (!b) throw new Error(`no block containing ${JSON.stringify(substr)}`);
return b.index;
};
const edgeKinds = (cfg: FunctionCfg): Set<string> => new Set(cfg.edges.map((e) => e.kind));
/** Does control reach `to` from `from` following edges? */
function reaches(cfg: FunctionCfg, from: number, to: number): boolean {
const adj = new Map<number, number[]>();
for (const e of cfg.edges) (adj.get(e.from) ?? adj.set(e.from, []).get(e.from)!).push(e.to);
const seen = new Set([from]);
const stack = [from];
while (stack.length) {
const n = stack.pop() as number;
if (n === to) return true;
for (const nx of adj.get(n) ?? []) if (!seen.has(nx)) (seen.add(nx), stack.push(nx));
}
return seen.has(to);
}
describe('Vue CfgVisitor reuse — SFC <script> → TypeScript CFG', () => {
it('extracts the <script setup> block and builds one CFG per script function', () => {
const sfc = fs.readFileSync(FIXTURE, 'utf8');
const extraction = extractVueScript(sfc);
expect(extraction).not.toBeNull();
// `lang="ts"` ⇒ TypeScript grammar wins (the worker's mapping).
expect(extraction?.lang).toBe('');
expect(extraction?.isSetup).toBe(true);
const cfgs = cfgsOfSfc(sfc);
// classify, eventLoop, onClick, plus the small helpers — many CFG-bearing fns.
expect(cfgs.length).toBeGreaterThanOrEqual(1);
for (const cfg of cfgs) {
expect(cfg.blocks[cfg.entryIndex].kind).toBe('entry');
expect(cfg.blocks[cfg.exitIndex].kind).toBe('exit');
}
});
it('if/else-if/else in the script produces a branching CFG (cond-true + cond-false)', () => {
const cfgs = cfgsOfSfc(fs.readFileSync(FIXTURE, 'utf8'));
// Locate the `classify` function's CFG by its distinctive arm text.
const classify = cfgs.find((c) => c.blocks.some((b) => b.text.includes('positive()')));
expect(classify).toBeDefined();
if (!classify) return;
const kinds = edgeKinds(classify);
expect(kinds.has('cond-true')).toBe(true);
expect(kinds.has('cond-false')).toBe(true);
// each arm rejoins and reaches EXIT
for (const arm of ['positive()', 'negative()', 'zero()']) {
expect(reaches(classify, classify.entryIndex, block(classify, arm))).toBe(true);
expect(reaches(classify, block(classify, arm), classify.exitIndex)).toBe(true);
}
});
it('a `while (true)` script keeps EXIT reverse-reachable and yields CDG > 0', () => {
// The smallest reproduction of the silent-zero hazard, through the SFC path.
const sfc = `
<script setup lang="ts">
function loopForever(x: number): number {
let sum = 0;
while (true) {
if (shouldStop(x)) {
return sum;
}
sum = sum + x;
}
}
</script>
`;
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);
}
});
});