From 3c9d9ace0fe92ea49dfb236bbcf82fb4069d69d2 Mon Sep 17 00:00:00 2001 From: Sohil Kshirsagar Date: Fri, 10 Apr 2026 13:57:52 -0700 Subject: [PATCH 1/3] fix: expand multi-line statements to all lines in coverage data MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit V8 coverage uses byte offsets which ast-v8-to-istanbul converts to Istanbul's statementMap with start/end line numbers. Previously we only recorded the start line of each statement, so multi-line statements like res.json({...}) spanning lines 14-20 only showed line 14 as covered. Now we expand each statement to all lines in its range. Statements are processed largest-first so inner (more specific) statements override outer ones — e.g. a try-catch block (covered) contains a catch body (uncovered), and the catch body correctly stays uncovered. Extract extractLineCoverage() as a standalone testable function with 4 unit tests covering: multi-line expansion, single-line statements, overlapping statement override, and empty input. Before: 81.2% (52/64 lines) on example-express-server After: 87.8% (144/164 lines) — correct count with full line tracking --- src/core/coverageProcessor.test.ts | 71 ++++++++++++++++++++++++++++++ src/core/coverageProcessor.ts | 52 +++++++++++++++++----- 2 files changed, 113 insertions(+), 10 deletions(-) diff --git a/src/core/coverageProcessor.test.ts b/src/core/coverageProcessor.test.ts index 8d9d134..d2312ca 100644 --- a/src/core/coverageProcessor.test.ts +++ b/src/core/coverageProcessor.test.ts @@ -1,5 +1,6 @@ import test from "ava"; import { + extractLineCoverage, filterScriptUrl, takeAndProcessSnapshot, V8CoverageData, @@ -216,3 +217,73 @@ class Example {} t.truthy(ast); t.is(ast.type, "File"); }); + +// --- extractLineCoverage --- + +test("extractLineCoverage: expands multi-line statements to all lines", (t) => { + // res.json({ ... }) spanning lines 14-20, covered + const statementMap = { + "0": { start: { line: 14, column: 2 }, end: { line: 20, column: 4 } }, + }; + const statementCounts = { "0": 1 }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + t.is(lines["14"], 1); + t.is(lines["15"], 1); + t.is(lines["16"], 1); + t.is(lines["17"], 1); + t.is(lines["18"], 1); + t.is(lines["19"], 1); + t.is(lines["20"], 1); +}); + +test("extractLineCoverage: single-line statement works", (t) => { + const statementMap = { + "0": { start: { line: 5, column: 0 }, end: { line: 5, column: 30 } }, + }; + const statementCounts = { "0": 3 }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + t.is(lines["5"], 3); + t.is(lines["4"], undefined); + t.is(lines["6"], undefined); +}); + +test("extractLineCoverage: inner uncovered statement overrides outer covered statement", (t) => { + // Simulates: try { ... } catch (error) { res.status(500).json({...}) } + // Outer try-catch block (lines 25-40) is covered (count=1) + // Inner catch body (lines 36-39) is uncovered (count=0) + const statementMap = { + "0": { start: { line: 25, column: 0 }, end: { line: 40, column: 1 } }, + "1": { start: { line: 26, column: 4 }, end: { line: 27, column: 5 } }, + "2": { start: { line: 36, column: 4 }, end: { line: 39, column: 5 } }, + }; + const statementCounts = { + "0": 1, // try-catch executed + "1": 1, // try body executed + "2": 0, // catch body NOT executed + }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + // Try body lines should be covered + t.is(lines["26"], 1); + t.is(lines["27"], 1); + + // Catch body lines should be uncovered (inner statement overrides outer) + t.is(lines["36"], 0); + t.is(lines["37"], 0); + t.is(lines["38"], 0); + t.is(lines["39"], 0); + + // Outer try-catch structural lines are covered + t.is(lines["25"], 1); + t.is(lines["40"], 1); +}); + +test("extractLineCoverage: handles empty statement map", (t) => { + const lines = extractLineCoverage({}, {}); + t.deepEqual(lines, {}); +}); diff --git a/src/core/coverageProcessor.ts b/src/core/coverageProcessor.ts index b4dd2fa..d997f78 100644 --- a/src/core/coverageProcessor.ts +++ b/src/core/coverageProcessor.ts @@ -53,6 +53,45 @@ export interface V8CoverageData { result: V8ScriptCoverage[]; } +interface IstanbulStatementMap { + start: { line: number; column: number }; + end: { line: number; column: number }; +} + +/** + * Extract per-line execution counts from Istanbul's statement map. + * + * Multi-line statements (e.g. res.json({...}) spanning lines 14-20) are + * expanded to all lines in the range. When statements overlap (e.g. a + * try-catch block contains a catch body), the innermost (most specific) + * statement wins — so the catch body stays uncovered even though the + * outer try-catch block is covered. + */ +export function extractLineCoverage( + statementMap: Record, + statementCounts: Record, +): Record { + const stmtEntries = Object.entries(statementCounts) + .map(([stmtId, count]) => ({ + stmtMap: statementMap[stmtId], + count, + })) + .filter((e) => e.stmtMap) + .sort((a, b) => { + const sizeA = a.stmtMap.end.line - a.stmtMap.start.line; + const sizeB = b.stmtMap.end.line - b.stmtMap.start.line; + return sizeB - sizeA; // largest first + }); + + const lines: Record = {}; + for (const { stmtMap, count } of stmtEntries) { + for (let lineNum = stmtMap.start.line; lineNum <= stmtMap.end.line; lineNum++) { + lines[String(lineNum)] = count; + } + } + return lines; +} + /** * Filter a script URL to determine if it's a user source file. */ @@ -286,17 +325,10 @@ export async function processV8CoverageFile( if (!fileKey) continue; const fileCov = istanbulData[fileKey]; - // Extract line coverage from Istanbul statement map - const lines: Record = {}; - for (const [stmtId, count] of Object.entries( + const lines = extractLineCoverage( + fileCov.statementMap, fileCov.s as Record, - )) { - const stmtMap = fileCov.statementMap[stmtId]; - if (stmtMap) { - const line = String(stmtMap.start.line); - lines[line] = Math.max(lines[line] ?? 0, count); - } - } + ); // Extract branch coverage from Istanbul branch map let totalBranches = 0; From ca678ae637f6c4a3bca50f77c04a0d88af634a84 Mon Sep 17 00:00:00 2001 From: Sohil Kshirsagar Date: Fri, 10 Apr 2026 14:11:20 -0700 Subject: [PATCH 2/3] fix: use t.false for key-not-in-object assertions (TS2345) --- src/core/coverageProcessor.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/core/coverageProcessor.test.ts b/src/core/coverageProcessor.test.ts index d2312ca..f65032a 100644 --- a/src/core/coverageProcessor.test.ts +++ b/src/core/coverageProcessor.test.ts @@ -247,8 +247,8 @@ test("extractLineCoverage: single-line statement works", (t) => { const lines = extractLineCoverage(statementMap, statementCounts); t.is(lines["5"], 3); - t.is(lines["4"], undefined); - t.is(lines["6"], undefined); + t.false("4" in lines); + t.false("6" in lines); }); test("extractLineCoverage: inner uncovered statement overrides outer covered statement", (t) => { From 0923b86208a083485cc83bc39473d38d94462630 Mon Sep 17 00:00:00 2001 From: Sohil Kshirsagar Date: Fri, 10 Apr 2026 14:22:01 -0700 Subject: [PATCH 3/3] fix: use max count for same-size statements on the same line Review bots correctly identified that unconditional last-write-wins could mark covered lines as uncovered when two same-size statements map to the same line (e.g. if(x) foo(); else bar();). Now track the statement size per line: strictly smaller (inner) statements override, same-size statements use Math.max so covered wins. Add test for same-size overlap case. --- src/core/coverageProcessor.test.ts | 20 ++++++++++++++++++++ src/core/coverageProcessor.ts | 17 ++++++++++++++++- 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/core/coverageProcessor.test.ts b/src/core/coverageProcessor.test.ts index f65032a..155d2a7 100644 --- a/src/core/coverageProcessor.test.ts +++ b/src/core/coverageProcessor.test.ts @@ -283,6 +283,26 @@ test("extractLineCoverage: inner uncovered statement overrides outer covered sta t.is(lines["40"], 1); }); +test("extractLineCoverage: same-size statements use max count (covered wins)", (t) => { + // Two single-line statements on the same line: if (x) foo(); else bar(); + // foo() was called (count=1), bar() was not (count=0) + const statementMap = { + "0": { start: { line: 10, column: 0 }, end: { line: 10, column: 40 } }, + "1": { start: { line: 10, column: 7 }, end: { line: 10, column: 20 } }, + "2": { start: { line: 10, column: 26 }, end: { line: 10, column: 40 } }, + }; + const statementCounts = { + "0": 1, // if statement executed + "1": 1, // foo() called + "2": 0, // bar() not called + }; + + const lines = extractLineCoverage(statementMap, statementCounts); + + // Line 10 should be covered because at least one statement on it was executed + t.is(lines["10"], 1); +}); + test("extractLineCoverage: handles empty statement map", (t) => { const lines = extractLineCoverage({}, {}); t.deepEqual(lines, {}); diff --git a/src/core/coverageProcessor.ts b/src/core/coverageProcessor.ts index d997f78..b04afb1 100644 --- a/src/core/coverageProcessor.ts +++ b/src/core/coverageProcessor.ts @@ -84,9 +84,24 @@ export function extractLineCoverage( }); const lines: Record = {}; + // Track the size of the statement that last wrote each line, so + // same-size statements use Math.max (preserving covered status) + // while strictly smaller statements override (inner wins over outer). + const lineStmtSize: Record = {}; for (const { stmtMap, count } of stmtEntries) { + const size = stmtMap.end.line - stmtMap.start.line; for (let lineNum = stmtMap.start.line; lineNum <= stmtMap.end.line; lineNum++) { - lines[String(lineNum)] = count; + const key = String(lineNum); + const prevSize = lineStmtSize[key]; + if (prevSize === undefined || size < prevSize) { + // Strictly smaller (more specific) statement — override + lines[key] = count; + lineStmtSize[key] = size; + } else if (size === prevSize) { + // Same size — take max (covered wins) + lines[key] = Math.max(lines[key] ?? 0, count); + } + // size > prevSize: larger statement, skip — a more specific one already wrote this line } } return lines;