fix(cfg): put embedded-script CFGs in file coordinates via lineOffset (#2195)

A Vue SFC <script> block parses at row 0 but lives at lineOffset in the .vue
file. Every other worker-emitted graph node adds lineOffset to reach file
coordinates, but collectFunctionCfgs built FunctionCfgs from the extracted
script's raw rows and never offset them. Two consequences for .vue files:
- inter-procedural taint silently resolved NOTHING — the summary-harvest join
  keys graph Function/Method nodes by their (offset) startLine but looked up the
  CFG's (unoffset) functionStartLine, missing by exactly lineOffset, so no
  FunctionSummary was ever produced;
- persisted BasicBlock startLine/endLine (and the id's functionStartLine
  segment) pointed at the wrong .vue line, breaking source mapping.

Thread lineOffset into collectFunctionCfgs and shift every CFG source-line field
(functionStartLine/End, block start/end, statement + non-synthetic binding
lines) into file coordinates at the one production chokepoint. A 0 offset
returns the CFG unchanged, so .ts/.js/etc. stay byte-identical (bench --check
fingerprints unchanged; worker-roundtrip + pipeline-pdg green). Unit tests for
the shift + the 0-offset no-op added.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Gergo Magyar 2026-06-14 18:57:05 +00:00
parent 8a5478c6f0
commit 194c36befc
3 changed files with 65 additions and 1 deletions

View file

@ -30,11 +30,42 @@ export interface CollectedCfgs {
readonly skipped: number;
}
/**
* 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 `<script>`
* block parses at row 0 but lives at `lineOffset` in the `.vue` file, and every
* other worker-emitted graph node is already file-relative; without this, the
* CFG's `functionStartLine` would never join its Function/Method graph node
* (inter-procedural taint silently resolves nothing) and BasicBlock source
* lines would point at the wrong `.vue` line. A 0 offset returns the input
* unchanged (the common case: `.ts`/`.js`/etc. parse at the file root), keeping
* non-embedded languages byte-identical. Synthetic bindings keep `declLine` 0.
*/
function shiftCfgLines(cfg: FunctionCfg, offset: number): FunctionCfg {
if (offset === 0) return cfg;
return {
...cfg,
functionStartLine: cfg.functionStartLine + offset,
functionEndLine: cfg.functionEndLine + offset,
blocks: cfg.blocks.map((b) => ({
...b,
startLine: b.startLine + offset,
endLine: b.endLine + offset,
statements: b.statements?.map((s) => ({ ...s, line: s.line + offset })),
})),
bindings: cfg.bindings?.map((bd) =>
bd.declLine > 0 ? { ...bd, declLine: bd.declLine + offset } : bd,
),
};
}
export function collectFunctionCfgs(
root: SyntaxNode,
visitor: CfgVisitor<SyntaxNode>,
filePath: string,
maxFunctionLines = 0,
lineOffset = 0,
): CollectedCfgs {
const cfgs: FunctionCfg[] = [];
let skipped = 0;
@ -48,7 +79,7 @@ export function collectFunctionCfgs(
skipped++;
} else {
const cfg = visitor.buildFunctionCfg(node, filePath);
if (cfg) cfgs.push(cfg);
if (cfg) cfgs.push(shiftCfgLines(cfg, lineOffset));
}
}
// Descend regardless (a skipped mega-function may still contain small

View file

@ -1269,6 +1269,10 @@ const processFileGroup = (
provider.cfgVisitor,
file.path,
PDG_MAX_FUNCTION_LINES,
// Embedded scripts (Vue SFC <script>) parse at row 0 but live at
// `lineOffset` in the file — shift the CFG into file coordinates so
// it joins its graph node and BasicBlock lines map to source.
lineOffset,
);
if (cfgs.length) withChannels = { ...withChannels, cfgSideChannel: cfgs };
} catch (err) {

View file

@ -112,6 +112,35 @@ describe('U4 — emitFileCfgs node/edge shape', () => {
});
});
describe('collectFunctionCfgs — lineOffset → file coordinates (#2195 P1, Vue SFC)', () => {
it('shifts functionStartLine + block + statement lines by lineOffset', () => {
const code = `function f(x: number) { if (x) { a(); } else { b(); } }`;
const base = collectFunctionCfgs(tsRoot(code), visitor(), 'x.vue').cfgs;
const shifted = collectFunctionCfgs(tsRoot(code), visitor(), 'x.vue', 0, 5).cfgs;
expect(base.length).toBeGreaterThan(0);
expect(shifted).toHaveLength(base.length);
expect(shifted[0].functionStartLine).toBe(base[0].functionStartLine + 5);
expect(shifted[0].functionEndLine).toBe(base[0].functionEndLine + 5);
// functionStartColumn is a COLUMN, not a line — unchanged.
expect(shifted[0].functionStartColumn).toBe(base[0].functionStartColumn);
for (let i = 0; i < base[0].blocks.length; i++) {
expect(shifted[0].blocks[i].startLine).toBe(base[0].blocks[i].startLine + 5);
expect(shifted[0].blocks[i].endLine).toBe(base[0].blocks[i].endLine + 5);
}
// per-statement source lines shift too (file-accurate taint/explain hops).
const stmtLines = (cfgs: readonly FunctionCfg[]): number[] =>
cfgs[0].blocks.flatMap((b) => (b.statements ?? []).map((s) => s.line));
expect(stmtLines(shifted)).toEqual(stmtLines(base).map((l) => l + 5));
});
it('lineOffset 0 is a byte-identical no-op (non-embedded files unchanged)', () => {
const code = `function g() { while (true) { tick(); } }`;
const withZero = collectFunctionCfgs(tsRoot(code), visitor(), 'g.ts', 0, 0).cfgs;
const omitted = collectFunctionCfgs(tsRoot(code), visitor(), 'g.ts').cfgs;
expect(JSON.stringify(withZero)).toBe(JSON.stringify(omitted));
});
});
describe('U4 — AC2: every BasicBlock is reachable from its function ENTRY', () => {
// Fixtures deliberately contain no dead code, so the reachability closure
// from each function's ENTRY (block index 0) must cover all of its blocks.