diff --git a/.changeset/fix-v8-coverage-innermost-wins.md b/.changeset/fix-v8-coverage-innermost-wins.md new file mode 100644 index 00000000..5fa1682a --- /dev/null +++ b/.changeset/fix-v8-coverage-innermost-wins.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Fix V8 coverage ingest so innermost-wins applies across all FunctionCoverage entries in a script, not per-function iteration order. diff --git a/src/application/coverage-engine.test.ts b/src/application/coverage-engine.test.ts index 186b6da5..b72996fa 100644 --- a/src/application/coverage-engine.test.ts +++ b/src/application/coverage-engine.test.ts @@ -673,6 +673,65 @@ describe("coverage-engine", () => { } }); + it("innermost-wins across separate FunctionCoverage entries (inner before outer)", () => { + const source = [ + "function outer() {", + " function inner() { return 1; }", + " inner();", + "}", + ].join("\n"); + const { root, url } = makeTempProject(source); + const innerStart = source.indexOf("function inner"); + const innerEnd = source.indexOf("}", source.indexOf("inner")); + + const db = setupDb(); + try { + insertFile(db, { ...indexedFile("src/a.ts"), language: "ts" }); + insertSymbols(db, [ + fnSym("src/a.ts", "outer", 1, 4), + fnSym("src/a.ts", "inner", 2, 2), + ]); + + const scripts: V8ScriptCoverage[] = [ + { + scriptId: "1", + url, + functions: [ + { + functionName: "inner", + isBlockCoverage: true, + ranges: [ + { startOffset: innerStart, endOffset: innerEnd, count: 0 }, + ], + }, + { + functionName: "outer", + isBlockCoverage: true, + ranges: [ + { startOffset: 0, endOffset: source.length, count: 2 }, + ], + }, + ], + }, + ]; + + const result = ingestV8({ + db, + projectRoot: root, + scripts, + sourcePath: join(root, ".cov"), + }); + expect(result.ingested.symbols).toBe(2); + + const rows = db + .query("SELECT name, hit_statements FROM coverage ORDER BY name") + .all() as Array<{ name: string; hit_statements: number }>; + expect(rows.find((r) => r.name === "inner")!.hit_statements).toBe(0); + } finally { + closeDb(db); + } + }); + it("skips scripts whose url isn't a file:// URL (Node internals, eval)", () => { const db = setupDb(); try { diff --git a/src/application/coverage-engine.ts b/src/application/coverage-engine.ts index a44908f7..4f19e9f5 100644 --- a/src/application/coverage-engine.ts +++ b/src/application/coverage-engine.ts @@ -439,20 +439,18 @@ export function ingestV8(opts: V8ParserOpts): IngestResult { const scriptHits: (number | undefined)[] = new Array( lineOffsets.length + 1, ); - for (const fn of script.functions ?? []) { - const sorted = (fn.ranges ?? []) - .slice() - .sort( - (a, b) => - b.endOffset - b.startOffset - (a.endOffset - a.startOffset), - ); - for (const range of sorted) { - const startLine = offsetToLine(lineOffsets, range.startOffset); - const endLine = offsetToLine(lineOffsets, range.endOffset); - for (let line = startLine; line <= endLine; line++) { - // Innermost-wins within this script: last write is the smallest range. - scriptHits[line] = range.count; - } + // V8 may emit nested functions as separate entries; innermost-wins must be + // global across all ranges, not per FunctionCoverage iteration order. + const ranges = (script.functions ?? []).flatMap((fn) => fn.ranges ?? []); + ranges.sort( + (a, b) => b.endOffset - b.startOffset - (a.endOffset - a.startOffset), + ); + for (const range of ranges) { + const startLine = offsetToLine(lineOffsets, range.startOffset); + const endLine = offsetToLine(lineOffsets, range.endOffset); + for (let line = startLine; line <= endLine; line++) { + // Innermost-wins: last write is the smallest range. + scriptHits[line] = range.count; } } for (let line = 1; line < scriptHits.length; line++) {