Skip to content

feat(coverage): Add V8 code coverage collection#150

Merged
sohil-kshirsagar merged 27 commits intomainfrom
feat/code-coverage-tracking-poc
Apr 8, 2026
Merged

feat(coverage): Add V8 code coverage collection#150
sohil-kshirsagar merged 27 commits intomainfrom
feat/code-coverage-tracking-poc

Conversation

@sohil-kshirsagar
Copy link
Copy Markdown
Contributor

@sohil-kshirsagar sohil-kshirsagar commented Apr 3, 2026

Add V8 code coverage collection to the Node.js SDK. When the CLI enables coverage, the SDK processes V8 coverage data and returns per-file line/branch coverage via protobuf.

How it works

  1. CLI sets NODE_V8_COVERAGE env var → V8 enables precise coverage
  2. CLI sends CoverageSnapshotRequest via protobuf channel
  3. SDK calls v8.takeCoverage() (writes JSON, resets counters)
  4. SDK processes V8 data through ast-v8-to-istanbul for accurate line/branch coverage
  5. SDK returns CoverageSnapshotResponse with per-file data

Key design decisions

  • ast-v8-to-istanbul (not v8-to-istanbul): After counter reset, V8 only reports called functions. v8-to-istanbul marks missing functions as "covered by default" (wrong). ast-v8-to-istanbul parses the AST independently and correctly marks them as uncovered.
  • Lazy loading: acorn and ast-v8-to-istanbul are loaded via require() only when coverage is enabled, avoiding startup cost for 99% of users.
  • Quick-scan optimization: NODE_V8_COVERAGE is inherited by all child processes (npm, tsc, server). The SDK quick-scans each V8 JSON to skip non-server files before expensive AST processing.
  • Source map support: Automatically loads .js.map files, fixes TypeScript's sourceRoot: "/" issue, strips sourceMappingURL to prevent double-loading.

Files changed

  • src/core/coverageProcessor.ts — V8 coverage processing, source map handling, quick-scan
  • src/core/coverageProcessor.test.ts — filterScriptUrl tests
  • src/core/ProtobufCommunicator.ts — Coverage snapshot request handler
  • package.json — Added acorn, ast-v8-to-istanbul dependencies
  • docs/coverage.md — SDK coverage internals documentation
  • docs/environment-variables.md — Coverage env vars section

TODOs before merge

Edge cases / gotchas

  • TypeScript source maps with sourceRoot: "/" break ast-v8-to-istanbul's path resolution. The SDK strips sourceRoot and resolves sources relative to the project root.
  • CJS vs ESM: acorn tries sourceType: "script" first, falls back to "module". Handles both without config.
  • Files that fail acorn parsing (non-standard syntax, TypeScript without compilation) are silently skipped.

When NODE_V8_COVERAGE env var is set, the SDK starts a tiny HTTP server
on TUSK_COVERAGE_PORT (default 19876) that calls v8.takeCoverage() on
GET /snapshot. This allows the CLI to take V8 coverage snapshots between
tests for per-test coverage attribution.
Move coverage server startup to before early returns in initialize()
so it works regardless of SDK mode (RECORD/REPLAY/DISABLED).

Also reverted from V8 Inspector approach back to v8.takeCoverage()
after discovering Inspector's takePreciseCoverage only tracks scripts
loaded AFTER startPreciseCoverage is called, missing all user code
that loads at startup.
…keCoverage)

Replace NYC global.__coverage__ reading with V8's built-in coverage:
- v8.takeCoverage() auto-resets counters, giving clean per-test data
- SDK processes V8 JSON files in-process (byte offset -> line conversion)
- No external dependencies (no NYC, no c8)
- Works with CJS, ESM, TypeScript - anything V8 executes
- No command wrapping needed (just NODE_V8_COVERAGE env var)
- Near-zero overhead (no file I/O between snapshots since SDK cleans up)
When /snapshot?baseline=true is called, includes V8 ranges with count=0
(uncovered code) in the response. This provides the denominator for
coverage percentage calculation.

Note: V8's takeCoverage() output only includes scripts/functions that were
loaded, so truly unloaded files don't appear. This is acceptable since most
Node.js servers load all source files at startup.
V8 block coverage uses nested ranges (outer = function definition, inner =
branches/blocks). The innermost range gives the accurate count for each
byte position. Previously we used Math.max which let the outer range's
count=1 (function defined) override inner count=0 (body never called).

Now in baseline mode, ranges are processed in order (outermost first)
with later (more specific) ranges overwriting earlier ones. This correctly
produces count=0 for uncalled function bodies inside called scopes.
Previously per-test mode summed counts from all V8 ranges covering a line.
This caused outer function ranges (count=1) to override inner uncovered
ranges (count=0), making catch blocks and untaken branches appear covered.

Now both modes use innermost-range-wins. The only difference:
- Baseline: keeps count=0 lines (for denominator)
- Per-test: filters out count=0 lines (only executed code)

Result: Node coverage now accurately reports uncovered error handlers
and untaken branches, matching Python's coverage.py behavior.
Example: server.js went from 100% to 84.7%.
Move V8 coverage logic from TuskDrift.ts closures into coverageProcessor.ts:
- computeLineStarts: byte-offset-to-line mapping
- offsetToLine: binary search for line number
- processScriptCoverage: innermost-range-wins logic
- filterScriptUrl: user source file filtering
- processV8CoverageFile: full file processing with injectable reader
- takeAndProcessSnapshot: high-level snapshot + cleanup

Add 19 AVA tests covering:
- Line start computation edge cases
- Offset-to-line binary search
- Innermost range wins (catch blocks, untaken branches, uncalled functions)
- Baseline vs per-test mode (include/exclude count=0)
- Script URL filtering (node_modules, sourceRoot)
- Full file processing with mock reader
- Update CoverageResult to include per-file branch data (totalBranches,
  coveredBranches, per-line BranchInfo with total/covered)
- Extract branch data from V8 block coverage: inner ranges (index > 0)
  in functions with isBlockCoverage=true represent branch points
- Update tests for new FileCoverageData structure
- Note: V8 branch data may be incomplete after takeCoverage() reset
  (isBlockCoverage becomes false for some functions)
Replace manual V8 range processing with v8-to-istanbul library:
- Accurate branch detection (handles implicit taken branches that V8
  doesn't create separate ranges for)
- Source map support for TypeScript (v8-to-istanbul reads .map files)
- Standard Istanbul format output (statementMap, branchMap, fnMap)
- Branch coverage now shows real numbers: 58.8% (10/17) for demo app

v8-to-istanbul is the same library used by c8, Jest, and Vitest.

Note: line coverage interpretation changes slightly - Istanbul counts a
statement as covered if any range covering it has count > 0 (standard
behavior), vs our previous innermost-range-wins approach.
ast-v8-to-istanbul parses the source AST independently, so it correctly
handles partial V8 data after v8.takeCoverage() reset. Uncalled functions
(absent from V8 output) are correctly identified as uncovered.

v8-to-istanbul assumed complete V8 data and marked missing functions as
"covered by default", causing all 175 lines to show as covered even when
only /health was called.

Results with ast-v8-to-istanbul:
- Lines: 85.2% (52/61) for server.js ✓
- Branches: 42.9% (6/14) - real branch coverage from AST analysis ✓
- Per-test: 4, 1, 1, 13, 5, 4 lines - accurate per-test attribution ✓
- Supports TypeScript via sourceMap option
When processing V8 coverage files, detect //# sourceMappingURL= comments,
load the .map file, and pass it to ast-v8-to-istanbul. Coverage is then
reported against the original .ts source files instead of compiled .js.

Tested with example-express-ts-server:
- Before: dist/server.js 90.2% (55/61)
- After:  src/server.ts  90.2% (55/61)

Requirements for TypeScript projects:
- sourceMap: true in tsconfig.json (generates .js.map files)
- Source map files must be alongside compiled JS
- CLI sets TS_NODE_EMIT=true env var when coverage is enabled, forcing
  ts-node to write compiled JS + source maps to .ts-node/ directory
- SDK's resolveSourceCode() checks for .ts/.tsx files and looks for
  compiled JS in .ts-node/ or alongside the source file
- Falls back gracefully if compiled JS not found (file skipped)
Add docs/coverage.md explaining V8 coverage internals, ast-v8-to-istanbul,
source map handling, multi-PID quick-scan, and limitations. Update
environment-variables.md with coverage env vars section.
- Add acorn to dependencies (was only available as transitive dev dep)
- Add .catch() to async coverage handler to prevent unhandled rejections
- Use ecmaVersion "latest" for acorn parsing
- Remove dead legacy code (computeLineStarts, offsetToLine, processScriptCoverage)
- Fix branchLine null check (was producing "undefined" string key)
- Add eslint-disable comments for intentional lazy require() calls
- Remove unused counter variables
- Use const for coverage result
- Simplify resolveSourcePath (remove unused parameter)
- Use projectRoot consistently instead of process.cwd()
- URL decode file paths in filterScriptUrl
- Generalize sourceRoot handling for /, relative, and absolute paths
- Fix double JSON parse (pass pre-parsed data from quick-scan)
- Use OriginalGlobalUtils.getOriginalProcessEnvVar for NODE_V8_COVERAGE
  (process.env may be instrumented in replay mode)
- Change || to ?? for numeric defaults (project convention)
- Remove accidentally committed BUG_TRACKING.md files
@sohil-kshirsagar sohil-kshirsagar marked this pull request as ready for review April 7, 2026 02:39
@sohil-kshirsagar sohil-kshirsagar changed the title WIP feat(coverage): Add V8 code coverage collection feat(coverage): Add V8 code coverage collection Apr 7, 2026
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

2 issues found across 8 files

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/coverageProcessor.ts">

<violation number="1" location="src/core/coverageProcessor.ts:166">
P1: The `.ts-node` candidate path is constructed incorrectly (absolute segment + `.ts.js` suffix), so emitted TS output is not found and TS coverage can be skipped.</violation>

<violation number="2" location="src/core/coverageProcessor.ts:409">
P2: Using `Math.max` for `existing.covered` during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

const existing = coverage[filePath].branches[line];
if (existing) {
existing.total = Math.max(existing.total, branchInfo.total);
existing.covered = Math.max(existing.covered, branchInfo.covered);
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: Using Math.max for existing.covered during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/coverageProcessor.ts, line 409:

<comment>Using `Math.max` for `existing.covered` during cross-file merge can undercount branch coverage when different processes cover different branches on the same line.</comment>

<file context>
@@ -0,0 +1,432 @@
+          const existing = coverage[filePath].branches[line];
+          if (existing) {
+            existing.total = Math.max(existing.total, branchInfo.total);
+            existing.covered = Math.max(existing.covered, branchInfo.covered);
+          } else {
+            coverage[filePath].branches[line] = { ...branchInfo };
</file context>
Fix with Cubic

The first candidate for finding compiled JS in .ts-node/ was constructing
paths like server.ts.js instead of server.js. This caused ts-node coverage
to silently fail (falling back to raw TS which acorn can't parse).
…lisions

/app was matching /application. Now checks for trailing / or exact match,
same pattern as Python SDK's _is_user_file.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 2 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/coverageProcessor.ts">

<violation number="1" location="src/core/coverageProcessor.ts:74">
P2: The new path-boundary check rejects all files when `sourceRoot` is `/`, causing coverage to be empty in root-working-directory environments.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

When sourceRoot is "/" (common in Docker), sourceRoot + "/" becomes "//"
which matches nothing. Skip the boundary check when sourceRoot is root.
…fset alignment

Node.js wraps CJS modules with a 62-byte function header. V8 coverage
byte offsets include this wrapper, but our AST is parsed from the raw
source. Without passing wrapperLength, every line/branch coverage
mapping was shifted by ~62 bytes for CJS modules.
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

1 issue found across 1 file (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/core/coverageProcessor.ts">

<violation number="1" location="src/core/coverageProcessor.ts:237">
P1: `isCJS` is inferred from parse success in `script` mode, which misclassifies valid ESM files and applies `wrapperLength` incorrectly.</violation>
</file>

Reply with feedback, questions, or to request a fix. Tag @cubic-dev-ai to re-run a review, or fix all with cubic.

sourceType: "script",
locations: true,
});
isCJS = true;
Copy link
Copy Markdown
Contributor

@cubic-dev-ai cubic-dev-ai bot Apr 7, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1: isCJS is inferred from parse success in script mode, which misclassifies valid ESM files and applies wrapperLength incorrectly.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/core/coverageProcessor.ts, line 237:

<comment>`isCJS` is inferred from parse success in `script` mode, which misclassifies valid ESM files and applies `wrapperLength` incorrectly.</comment>

<file context>
@@ -224,14 +224,17 @@ export async function processV8CoverageFile(
           sourceType: "script",
           locations: true,
         });
+        isCJS = true;
       } catch {
         ast = acorn.parse(code, {
</file context>
Fix with Cubic

@tusk-dev
Copy link
Copy Markdown

tusk-dev bot commented Apr 7, 2026

Generated 11 tests - 11 passed

Commit tests View tests

Tip

New to Tusk Unit Tests? Learn more here.

Test Summary

  • handleCoverageSnapshotRequest - 3 ✓
  • filterScriptUrl - 7 ✓
  • takeAndProcessSnapshot - 1 ✓

Results

Tusk's tests all pass and validate the critical path for V8 coverage collection end-to-end. The handleCoverageSnapshotRequest tests confirm the protobuf communication layer works correctly—coverage data flows from V8 through processing and back to the CLI in the right format, with both line and branch info intact. The filterScriptUrl tests are the real MVP here: they cover the tricky edge cases that would silently break coverage accuracy in production (prefix collisions, URL encoding, Docker root paths, node_modules filtering). The takeAndProcessSnapshot test validates the quick-scan optimization that skips non-server files before expensive AST processing. Overall, this is solid coverage of the hot paths—the protobuf serialization, file filtering logic, and snapshot processing that determine whether coverage data is accurate or garbage.

View check history

Commit Status Output Created (UTC)
94c30ec Generated 20 tests - 20 passed Tests Apr 7, 2026 2:40AM
998944b Generated 16 tests - 16 passed Tests Apr 7, 2026 5:17AM
28e3e3a Generated 4 tests - 4 passed Tests Apr 7, 2026 8:25PM
c85da1d Generated 4 tests - 4 passed Tests Apr 7, 2026 9:15PM
a095168 Generated 4 tests - 4 passed Tests Apr 7, 2026 9:40PM
031ba83 Generated 17 tests - 17 passed Tests Apr 7, 2026 10:02PM
4997321 Generated 11 tests - 11 passed Tests Apr 7, 2026 10:09PM

Was Tusk helpful? Give feedback by reacting with 👍 or 👎

Copy link
Copy Markdown

@cursor cursor bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Cursor Bugbot has reviewed your changes and found 2 potential issues.

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit 4997321. Configure here.

sourceType: "module",
locations: true,
});
}
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CJS misdetection silently corrupts ESM file coverage offsets

Medium Severity

The CJS vs ESM detection relies on whether acorn successfully parses with sourceType: "script". ESM files that lack static import/export statements (e.g., files in a "type": "module" package using only dynamic import(), or simple utility files) parse as valid scripts, causing isCJS to be true. This incorrectly applies the ~62-byte CJS wrapper offset via require("module").wrapper[0].length, silently shifting all V8 coverage byte positions and producing wrong line/branch mappings for the entire file.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4997321. Configure here.

* identifies ALL functions/branches even when V8 only reports a subset
* (e.g., after v8.takeCoverage() reset). Missing functions = uncovered.
*/
export async function processV8CoverageFile(
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exported function only used within same file

Low Severity

processV8CoverageFile is exported but never imported outside coverageProcessor.ts — it's only called internally by takeAndProcessSnapshot. The export keyword unnecessarily widens the public API surface of this module.

Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 4997321. Configure here.

@sohil-kshirsagar sohil-kshirsagar merged commit 2b02c22 into main Apr 8, 2026
19 checks passed
@sohil-kshirsagar sohil-kshirsagar deleted the feat/code-coverage-tracking-poc branch April 8, 2026 00:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants