Просмотр исходного кода

Merge pull request #4116 from deepseek-harness/worktree/coverage-attribution

fix(coverage): canonicalize partition locations before the blob merge
Tianyi Cui 4 недель назад
Родитель
Сommit
9e87d16f3a

+ 6 - 0
.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md
+2026-09-13-partitioned-coverage-location-canonicalization.md: 54e5aaefe9551d1357f1c60201212873a1c7aef7
+2026-09-13-partitioned-coverage-location-canonicalization.zh.md: 1aa4b8de844a1f0e544d2bbb223641dbbfeff3da

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md

@@ -0,0 +1,43 @@
+# Agent Note: Canonicalize partition coverage locations before the blob merge
+
+Status: implemented
+
+English | [中文](2026-09-13-partitioned-coverage-location-canonicalization.zh.md)
+
+## Problem
+
+The coverage gate reported [packages/util/home-paths/src/index.ts](../../../../packages/util/home-paths/src/index.ts) at 96.96% statements with one uncovered statement at `48:22`, while branches, functions, and lines stayed at 100% and the same tests reported the file at 100% in an unpartitioned run. Line 48 holds one statement, so the merged report counted a second, unhit statement the source does not contain. The branch that exposed the failure only added a jsdom suite that loads a node-tested module; it did not touch that module or its package.
+
+A source file reaches the merged report through one statement map per Vite environment, and the serialized partition blob strips the location data istanbul-lib-coverage reconciles those maps with. A file executed under two environments can therefore fail the per-file 100% gate although every statement ran, and the failure follows the test environments and the partition count rather than the file.
+
+## Decision
+
+[scripts/coverage-partitions.ts](../../../../scripts/coverage-partitions.ts) passes every partition `--reporter=./scripts/coverage-canonical-locations.ts`, whose `onCoverage` hook rewrites each non-finite end column of the finished run's coverage map to `Number.MAX_SAFE_INTEGER`. That column keeps the meaning of a location that ends at its line's end, serializes as a number, and keys identically in every blob, so the merge command reconciles environment-specific spellings exactly as an in-process merge does. The per-file 100% gate keeps its full strength for every file `coverage.include` matches: the canonicalization adds hits through istanbul's containment rule and never removes a statement from the report. A payload that carries no istanbul `data` record fails the partition instead of leaving every location uncanonicalized.
+
+[scripts/coverage-uncovered-locations.cjs](../../../../scripts/coverage-uncovered-locations.cjs) reads the same column as a line end, so an uncovered record prints the same `path:line:col` with or without the canonicalization. The [in-job partitioned coverage](../process/2026-08-18-in-job-partitioned-coverage.md) coordinator owns the partition and merge commands this reporter joins, and it keeps its single merged threshold check.
+
+## Divergent statement maps across Vite environments
+
+A node suite maps a source file through the `ssr` environment and a `@vitest-environment jsdom` suite maps it through the `client` environment. The AST-based V8 remapper positions a statement at the node it finds in the transformed code, so one declaration enters the merged map twice: at its declared identifier from the ssr transform, at the nested call expression of the client transform. istanbul-lib-coverage attributes the hits of the narrowest containing range to an entry no other record names, which covers whichever spelling the client record introduces.
+
+That reconciliation runs on locations `getLoc()` accepts, which requires numeric line and column values. ast-v8-to-istanbul ends a whole-line statement at column `Infinity`; a partition blob serializes `Infinity` as `null`, so the merge command holds a location it cannot compare and keeps the client-only spelling as an extra, unhit statement. The same records merged inside one process, where `Infinity` survives, report no such statement.
+
+## Testing
+
+`scripts/coverage-partitions.spec.ts` merges two records that spell one statement both ways through the JSON hop a blob performs, and asserts that the canonicalized merge reports no uncovered statement where the raw merge reports the phantom. A second case pins the canonicalization across statement, function, and branch locations, a third pins the canonicalizing reporter on every partition command, and a fourth pins the loud rejection of a payload that carries no coverage data.
+
+## Alternatives considered
+
+**Drop the untested-file maps from partitions.** Rejected because those maps are what fails a file no test runs; removing them silences a real coverage gap in exchange for the phantom.
+
+**Reconcile statements by line in the merge command.** Rejected because several statements can share a line, so line-level merging hides genuinely uncovered code.
+
+**Keep jsdom suites from loading node-only modules.** Rejected because attribution must not depend on which environment a suite happens to load a module in, and any later cross-environment load would return the defect.
+
+**Repair the merged map in the report phase.** Rejected because the records are already fused by then, so re-adding hits cannot separate a phantom spelling from a genuinely unhit nested statement.
+
+**Exempt the file or relax the per-file gate.** Rejected because the file is covered and the gate is correct; the attribution was not.
+
+## Consequences
+
+Partitioned runs attribute for a cross-environment file what one process attributes, so the gate holds 100% per file without exempting anything. Canonicalization runs inside partitions only, which leaves unpartitioned runs and their reports as they are. Blobs carry a finite sentinel column for a line-end position, and both the partition reporter and the uncovered-locations reporter name that sentinel as the line-end convention. The canonicalization also depends on Vitest's reporter ordering: the blob reporter stores the map in its own `onCoverage` and serializes it in `onTestRunEnd`, so a Vitest upgrade that reordered those hooks would first show up as the phantom statement returning.

+ 43 - 0
.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.zh.md

@@ -0,0 +1,43 @@
+# Agent Note: 在 blob 合并前规范化分区覆盖率位置
+
+Status: implemented
+
+[English](2026-09-13-partitioned-coverage-location-canonicalization.md) | 中文
+
+## 问题
+
+覆盖率门禁把 [packages/util/home-paths/src/index.ts](../../../../packages/util/home-paths/src/index.ts) 报为语句 96.96%,并在 `48:22` 标出 1 条未覆盖语句,而分支、函数与行都保持 100%,同一批测试在非分区运行中把该文件报为 100%。第 48 行只有 1 条语句,因此合并报告计入了源码中并不存在的第 2 条未命中语句。暴露该失败的分支只新增了一个加载受节点侧测试覆盖模块的 jsdom 套件,并未改动该模块或其所属包。
+
+一个源文件在每个 Vite 环境中各有一份语句映射并据此进入合并报告,而序列化后的分区 blob 抹掉了 istanbul-lib-coverage 用来调和这些映射的位置数据。因此在两个环境中执行过的文件可能在每条语句都跑到的情况下仍未通过逐文件 100% 门禁,且该失败跟随测试环境与分区数量,而不跟随文件本身。
+
+## 决策
+
+[scripts/coverage-partitions.ts](../../../../scripts/coverage-partitions.ts) 为每个分区传入 `--reporter=./scripts/coverage-canonical-locations.ts`;该报告器的 `onCoverage` 钩子把当次运行覆盖率映射中所有非有限的结束列改写为 `Number.MAX_SAFE_INTEGER`。该列保留“位置结束于所在行行尾”的含义,序列化后仍是数字,并且在每个 blob 中生成相同的键,因此合并命令会像进程内合并那样调和各环境特有的写法。逐文件 100% 门禁对 `coverage.include` 命中的每个文件保持完整强度:规范化只会通过 istanbul 的包含规则增加命中,绝不会把语句移出报告。载荷若不含 istanbul 的 `data` 记录,分区会直接失败,而不是让所有位置保持未规范化。
+
+[scripts/coverage-uncovered-locations.cjs](../../../../scripts/coverage-uncovered-locations.cjs) 把同一列读作行尾,因此无论是否经过规范化,未覆盖记录打印的 `path:line:col` 都相同。[单 job 分区覆盖率](../process/2026-08-18-in-job-partitioned-coverage.zh.md)协调器拥有该报告器所加入的分区与合并命令,并保留其唯一一次合并阈值判定。
+
+## 跨 Vite 环境的语句映射分歧
+
+节点侧套件把源文件映射到 `ssr` 环境,`@vitest-environment jsdom` 套件把它映射到 `client` 环境。基于 AST 的 V8 重映射器按它在转换后代码中找到的节点定位语句,因此同一条声明会以两种写法进入合并映射:ssr 转换给出其声明标识符的位置,client 转换给出其嵌套调用表达式的位置。istanbul-lib-coverage 会把最窄包含范围的命中计入其他记录都未命名的条目,从而覆盖 client 记录引入的那种写法。
+
+该调和只作用于 `getLoc()` 接受的位置,这要求行列值都是数字。ast-v8-to-istanbul 把整行语句的结束列标为 `Infinity`;分区 blob 会把 `Infinity` 序列化为 `null`,于是合并命令拿到无法比较的位置,并把仅存在于 client 的写法保留为额外的未命中语句。同样的记录在单个进程内合并时 `Infinity` 得以保留,因此不会报出该语句。
+
+## 验证
+
+`scripts/coverage-partitions.spec.ts` 让两条以两种写法表示同一语句的记录经过 blob 实际执行的 JSON 跳转后再合并,断言规范化后的合并没有未覆盖语句,而未经规范化的合并会报出该幻影语句。第二个用例固定语句、函数与分支位置的规范化,第三个用例固定每个分区命令都带上该规范化报告器,第四个用例固定对不含覆盖率数据的载荷的显式拒绝。
+
+## 曾考虑的替代方案
+
+**在分区中丢弃未测试文件映射。** 不予采用,因为这些映射正是让没有任何测试执行的文件失败的依据;去掉它们等于用真实的覆盖率缺口换取该幻影语句。
+
+**在合并命令中按行调和语句。** 不予采用,因为同一行可以承载多条语句,按行合并会掩盖真正未覆盖的代码。
+
+**禁止 jsdom 套件加载仅节点侧模块。** 不予采用,因为归因不能取决于套件恰好把模块加载到哪个环境,而且此后任何跨环境加载都会让该缺陷重现。
+
+**在报告阶段修复合并后的映射。** 不予采用,因为此时各记录已经融合,补回命中无法区分幻影写法与真正未命中的嵌套语句。
+
+**豁免该文件或放宽逐文件门禁。** 不予采用,因为该文件确有覆盖,门禁本身正确,错的是归因。
+
+## 后果
+
+分区运行对跨环境文件给出与单进程相同的归因,因此门禁在不豁免任何文件的前提下保持逐文件 100%。规范化只在分区内部运行,未分区运行及其报告保持原样。blob 在行尾位置携带一个有限哨兵列,分区报告器与未覆盖位置报告器都把该哨兵命名为行尾约定。该规范化还依赖 Vitest 的报告器次序:blob 报告器在自己的 `onCoverage` 中保存该映射,在 `onTestRunEnd` 中序列化它,因此升级 Vitest 后若次序改变,最先表现为幻影语句复现。

+ 2 - 2
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md
-2026-08-18-in-job-partitioned-coverage.md: 8ac4635101504419c976a5ab740b691a090e548f
-2026-08-18-in-job-partitioned-coverage.zh.md: 55dee82662c7174d661c253463153ca78e3099b2
+2026-08-18-in-job-partitioned-coverage.md: 3eafecc02f45b470e13ca426ad005f053a852472
+2026-08-18-in-job-partitioned-coverage.zh.md: 457e352992519b1845b0a592cc9d5a235d29df9d

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.md

@@ -16,7 +16,7 @@ The ordinary `pnpm run test:coverage` command remains one Vitest invocation. Lin
 
 The entire `packages/typert/` group is exempt from source coverage and runs in the uninstrumented gate. Its compiler fixtures, catalog reproduction, loader, protocol, and registry assertions remain required. The shared [exempt roster](../../../../scripts/coverage-exempt.ts) selects every Typert package, including nested tests; both project exclusions and partition inventory consume that roster. Exemption removes coverage collection, not test or hook failures.
 
-When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker. The coordinator collects the instrumented inventory from a `vitest list --filesOnly` run (caller filters narrow it; exempt heavy suites are removed because list does not apply their exclusion), reads recorded per-file durations from a coordinator-persisted gitignored file (restored and saved through the GitHub cache on the windows-coverage job, because checkout removes it and Vitest's own cache never survives CI), and assigns files to partitions by longest-processing-time by way of a min-heap, so the heavy subprocess-bound suites spread across children instead of piling into whichever shard a path hash lands them in. Each partition receives a temporary Vitest config whose include is its file list per project (command-line files exceeded the Windows CreateProcess limit; the mutually exclusive thread-safe and process-bound projects keep only their own files so nothing runs twice), an empty partition is rejected before any child starts, the heaviest partition starts first so its verdict lands earliest (fail-fast), and the duration history is restored and saved through the GitHub cache with per-run keys (cache entries are immutable). Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, and writes one blob report per process.
+When partitioning is enabled, `scripts/run-gates.ts` selects `pnpm run test:coverage:partitioned` for the instrumented gate. `scripts/coverage-partitions.ts` starts the configured Vitest children concurrently, each with one worker. The coordinator collects the instrumented inventory from a `vitest list --filesOnly` run (caller filters narrow it; exempt heavy suites are removed because list does not apply their exclusion), reads recorded per-file durations from a coordinator-persisted gitignored file (restored and saved through the GitHub cache on the windows-coverage job, because checkout removes it and Vitest's own cache never survives CI), and assigns files to partitions by longest-processing-time by way of a min-heap, so the heavy subprocess-bound suites spread across children instead of piling into whichever shard a path hash lands them in. Each partition receives a temporary Vitest config whose include is its file list per project (command-line files exceeded the Windows CreateProcess limit; the mutually exclusive thread-safe and process-bound projects keep only their own files so nothing runs twice), an empty partition is rejected before any child starts, the heaviest partition starts first so its verdict lands earliest (fail-fast), and the duration history is restored and saved through the GitHub cache with per-run keys (cache entries are immutable). Partition mode suppresses thresholds and coverage reporters in each child, gives every child a separate report directory, writes one blob report per process, and adds the [partition location canonicalizer](../bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md) as a Vitest reporter so each blob's locations stay mergeable.
 
 The coordinator waits for every child, validates that the blob directory contains exactly the expected files, and then runs one `vitest --merge-reports ... --coverage` command. Only that merged command applies the repository's per-file statement, branch, function, and line thresholds, so a partition is never judged against an intentionally partial inventory.
 

+ 1 - 1
.agents/notes/implemented/process/2026-08-18-in-job-partitioned-coverage.zh.md

@@ -16,7 +16,7 @@ Status: implemented
 
 整个 `packages/typert/` 组豁免源码覆盖率,并在无插桩门禁中运行。其编译器 fixture(测试前置数据)、目录复现、loader、协议及注册表断言仍为必需检查。共享的[豁免清单](../../../../scripts/coverage-exempt.ts)选择每个 Typert 包及其嵌套测试;project 排除规则与分区清单都使用该清单。豁免只移除覆盖率采集,不忽略测试或钩子失败。
 
-启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker。协调器通过 `vitest list --filesOnly` 收集插桩清单(调用方过滤器会先收窄清单;exempt 重型套件需在此剔除,因为 list 不应用其排除),从协调器持久化的 gitignore 文件读取逐文件耗时(windows-coverage job 通过 GitHub cache 恢复并保存该文件,因为 checkout 会删除它且 Vitest 自身缓存无法在 CI 存活),并借最小堆按最长处理时间把文件分配到各分区,使重量级子进程密集型套件分散到不同子进程,而不是全部落入路径 hash 恰好命中的那一个分片。每个分区获得一个临时 Vitest 配置,其 include 按 project 拆分(命令行传文件会超过 Windows CreateProcess 上限;互斥的 thread-safe 与 process-bound project 只保留各自的文件,避免任何文件跑两次);空分区会在任何子进程启动前被拒绝,最重的分区最先启动使其结论最早落地(fail-fast),耗时历史通过 GitHub cache 以每 run 唯一键恢复与保存(cache 条目不可变)。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,并让每个进程写出 1 份 blob 报告。
+启用分区后,`scripts/run-gates.ts` 为插桩门禁选择 `pnpm run test:coverage:partitioned`。`scripts/coverage-partitions.ts` 按配置数量并发启动 Vitest 子进程,每个进程只用 1 个 worker。协调器通过 `vitest list --filesOnly` 收集插桩清单(调用方过滤器会先收窄清单;exempt 重型套件需在此剔除,因为 list 不应用其排除),从协调器持久化的 gitignore 文件读取逐文件耗时(windows-coverage job 通过 GitHub cache 恢复并保存该文件,因为 checkout 会删除它且 Vitest 自身缓存无法在 CI 存活),并借最小堆按最长处理时间把文件分配到各分区,使重量级子进程密集型套件分散到不同子进程,而不是全部落入路径 hash 恰好命中的那一个分片。每个分区获得一个临时 Vitest 配置,其 include 按 project 拆分(命令行传文件会超过 Windows CreateProcess 上限;互斥的 thread-safe 与 process-bound project 只保留各自的文件,避免任何文件跑两次);空分区会在任何子进程启动前被拒绝,最重的分区最先启动使其结论最早落地(fail-fast),耗时历史通过 GitHub cache 以每 run 唯一键恢复与保存(cache 条目不可变)。分区模式会在各子进程中关闭阈值与覆盖率报告器,为每个子进程分配独立报告目录,让每个进程写出 1 份 blob 报告,并额外挂载[分区位置规范化器](../bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.zh.md)这一 Vitest 报告器,使每份 blob 的位置保持可合并。
 
 协调器等待全部子进程结束,验证 blob 目录只包含预期文件,然后执行一次 `vitest --merge-reports ... --coverage`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
 

+ 94 - 0
scripts/coverage-canonical-locations.ts

@@ -0,0 +1,94 @@
+/**
+ * Canonicalize coverage locations inside a coverage partition, before its
+ * blob is written.
+ *
+ * Vitest reaches every reporter's `onCoverage` with the finished run's coverage
+ * map and lets the blob reporter serialize that same map later, in
+ * `onTestRunEnd`, so this reporter sees the locations while they are numeric.
+ * ast-v8-to-istanbul ends a whole-line statement at column `Infinity`, which the
+ * blob's JSON hop turns into `null`; istanbul-lib-coverage reconciles the
+ * per-environment spellings of one statement only through numeric columns, so a
+ * `null` column leaves a client-only spelling behind as a phantom uncovered
+ * statement. Replacing each non-finite end column with
+ * {@link END_OF_LINE_COLUMN} keeps the line-end meaning and keys identically in
+ * every blob, so the merge attributes the coverage one process would have.
+ *
+ * @see ../.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md
+ * @module
+ */
+
+/**
+ * End column of a location that spans to the end of its start line, and the
+ * finite stand-in for ast-v8-to-istanbul's unrepresentable `Infinity`.
+ */
+export const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER
+
+/** Mutable structural view of the istanbul coverage map handed to reporters. */
+type CoverageRecord = Record<string, unknown>
+
+/** Whether a value is a non-null object usable as a mutable record. */
+function isRecord(value: unknown): value is CoverageRecord {
+  return typeof value === 'object' && value !== null
+}
+
+/** Rewrite one location's non-finite end column in place. */
+function canonicalizeLocation(location: unknown): void {
+  if (!isRecord(location)) return
+  const end = location['end']
+  if (!isRecord(end)) return
+  const column = end['column']
+  if (typeof column === 'number' && Number.isFinite(column)) return
+  end['column'] = END_OF_LINE_COLUMN
+}
+
+/** Rewrite every location a statement, function, or branch entry carries. */
+function canonicalizeEntry(entry: unknown): void {
+  if (!isRecord(entry)) return
+  // A statement is a location itself; a function carries `decl` plus `loc`, and
+  // a branch carries `loc` plus one location per branch path.
+  canonicalizeLocation(entry)
+  canonicalizeLocation(entry['decl'])
+  canonicalizeLocation(entry['loc'])
+  const locations = entry['locations']
+  if (Array.isArray(locations)) for (const location of locations) canonicalizeLocation(location)
+}
+
+/**
+ * Replace every non-finite end column of an istanbul coverage map in place.
+ * @param coverageMap - istanbul `CoverageMap` whose `data` holds per-file coverage.
+ * @throws Error when the payload carries no istanbul `data` record, so a Vitest
+ * change that stops handing reporters that map fails the partition instead of
+ * leaving every location uncanonicalized. Inner structure a `CoverageMap`
+ * always carries is read without further checks.
+ */
+export function canonicalizeEndOfLineColumns(coverageMap: unknown): void {
+  const files = isRecord(coverageMap) ? coverageMap['data'] : undefined
+  if (!isRecord(files)) {
+    throw new Error('coverage-canonical-locations: onCoverage payload is not an istanbul CoverageMap')
+  }
+  for (const file of Object.values(files)) {
+    if (!isRecord(file)) continue
+    // FileCoverage keeps its maps on `data` and mirrors them as getters.
+    const coverage = isRecord(file['data']) ? file['data'] : file
+    for (const name of ['statementMap', 'fnMap', 'branchMap']) {
+      const entries = coverage[name]
+      if (!isRecord(entries)) continue
+      for (const entry of Object.values(entries)) canonicalizeEntry(entry)
+    }
+  }
+}
+
+/**
+ * Vitest reporter that canonicalizes a partition's coverage map in
+ * {@link CanonicalCoverageLocationsReporter.onCoverage}, the hook Vitest runs
+ * before the blob reporter serializes that map.
+ */
+export default class CanonicalCoverageLocationsReporter {
+  /**
+   * Canonicalize the finished run's coverage locations.
+   * @param coverageMap - coverage map Vitest hands to every reporter.
+   */
+  public onCoverage(coverageMap: unknown): void {
+    canonicalizeEndOfLineColumns(coverageMap)
+  }
+}

+ 183 - 1
scripts/coverage-partitions.spec.ts

@@ -1,6 +1,8 @@
 import { access, mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promises'
+import { createRequire } from 'node:module'
 import { tmpdir } from 'node:os'
-import { dirname, join } from 'node:path'
+import { dirname, join, resolve } from 'node:path'
+import { fileURLToPath } from 'node:url'
 import { afterEach, describe, expect, it, vi } from 'vitest'
 import {
   COVERAGE_PARTITION_MODE_ENV,
@@ -19,6 +21,10 @@ import {
   type CoverageCommandResult,
   type CoveragePartitionCoordinatorOptions,
 } from './coverage-partitions.ts'
+import {
+  END_OF_LINE_COLUMN,
+  canonicalizeEndOfLineColumns,
+} from './coverage-canonical-locations.ts'
 
 const passed: CoverageCommandResult = { exitCode: 0, signalCode: null }
 
@@ -273,6 +279,154 @@ describe('coverage file inventory', () => {
   })
 })
 
+/**
+ * One source statement as two Vite environments spell it: the ssr map ends the
+ * declaration at its identifier, the jsdom/client map at the nested call, and
+ * both stop at their own line's end.
+ */
+const CRASH_LOOP = { start: { line: 36, column: 2 }, end: { line: 54, column: Infinity } }
+
+/** File path the canonicalization fixtures share. */
+const CANONICALIZED_FILE = 'packages/util/home-paths/src/index.ts'
+
+/** Istanbul statement location in the fixture's own terms. */
+interface StatementLocation {
+  start: { line: number; column: number }
+  end: { line: number; column: number }
+}
+
+/** Statement hits and locations read back from a merged istanbul map. */
+interface MergedStatements {
+  s: Record<string, number>
+  statementMap: Record<string, StatementLocation>
+}
+
+/** The istanbul entry points the merge assertions use. */
+interface CoverageLibrary {
+  createCoverageMap: (data: unknown) => {
+    merge: (data: unknown) => void
+    fileCoverageFor: (file: string) => MergedStatements
+  }
+}
+
+/**
+ * istanbul-lib-coverage is the merge implementation the partition blobs feed.
+ * It arrives as a dependency of the declared istanbul-lib-report devDependency,
+ * so this spec resolves it through that owner instead of declaring its own copy.
+ */
+const coverageLibrary = createRequire(
+  createRequire(import.meta.url).resolve('istanbul-lib-report'),
+)('istanbul-lib-coverage') as CoverageLibrary
+
+/** One file's statement coverage, shaped as a partition blob carries it. */
+function statementRecord(statements: StatementLocation[], hits: number[]): Record<string, unknown> {
+  return {
+    [CANONICALIZED_FILE]: {
+      path: CANONICALIZED_FILE,
+      statementMap: Object.fromEntries(statements.map((location, index) => [index, location])),
+      s: Object.fromEntries(hits.map((hit, index) => [index, hit])),
+      fnMap: {},
+      f: {},
+      branchMap: {},
+      b: {},
+    },
+  }
+}
+
+/**
+ * Merge partition records the way the coordinator's merge command does. The
+ * blob serializer's JSON hop turns a non-finite end column into `null`, which
+ * is what strips istanbul of the range containment it reconciles records with.
+ * Canonicalization runs on the `CoverageMap` the reporter hook receives, whose
+ * `data` holds the raw per-file records merged here.
+ */
+function mergePartitionRecords(
+  records: Array<Record<string, unknown>>,
+  canonicalize: boolean,
+): MergedStatements {
+  const map = coverageLibrary.createCoverageMap({})
+  for (const record of records) {
+    if (canonicalize) canonicalizeEndOfLineColumns({ data: record })
+    map.merge(JSON.parse(JSON.stringify(record)) as unknown)
+  }
+  return map.fileCoverageFor(CANONICALIZED_FILE)
+}
+
+/** Start positions of the statements the merged map still reports as unhit. */
+function uncoveredStarts(coverage: MergedStatements): Array<{ line: number; column: number }> {
+  return Object.entries(coverage.s)
+    .filter(([, hits]) => hits === 0)
+    .map(([index]) => coverage.statementMap[index])
+    .filter((location): location is StatementLocation => location !== undefined)
+    .map(location => location.start)
+}
+
+describe('coverage location canonicalization', () => {
+  // index.ts:48 as the ssr environment spells it (at `parent`) and as the
+  // client environment spells it (at `dirname(current)`); the loop holding
+  // both spellings ran in each, and only the client record never took the
+  // catch branch that owns the statement.
+  const ssrRecord = statementRecord(
+    [CRASH_LOOP, { start: { line: 48, column: 12 }, end: { line: 48, column: Infinity } }],
+    [3, 2],
+  )
+  const clientRecord = statementRecord(
+    [CRASH_LOOP, { start: { line: 48, column: 21 }, end: { line: 48, column: Infinity } }],
+    [5, 0],
+  )
+
+  it('drops the phantom statement a client-only spelling leaves after the blob merge', () => {
+    // 33 statements, 32 covered: the ssr spelling is hit while the client
+    // spelling of the same source statement stays unhit, so a file whose every
+    // statement ran fails the per-file 100% gate.
+    expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], false)))
+      .toEqual([{ line: 48, column: 21 }])
+
+    expect(uncoveredStarts(mergePartitionRecords([ssrRecord, clientRecord], true))).toEqual([])
+  })
+
+  it('canonicalizes line-end columns in statement, function, and branch locations', () => {
+    const lineEnd = (line: number): StatementLocation => ({
+      start: { line, column: 4 },
+      end: { line, column: Infinity },
+    })
+    const record = {
+      [CANONICALIZED_FILE]: {
+        path: CANONICALIZED_FILE,
+        statementMap: { 0: lineEnd(10), 1: { start: { line: 11, column: 4 }, end: { line: 11, column: 9 } } },
+        s: { 0: 1, 1: 1 },
+        fnMap: { 0: { name: 'probe', decl: lineEnd(10), loc: lineEnd(10) } },
+        f: { 0: 1 },
+        branchMap: { 0: { type: 'if', loc: lineEnd(12), locations: [lineEnd(12), lineEnd(13)] } },
+        b: { 0: [1, 1] },
+      },
+    }
+
+    canonicalizeEndOfLineColumns({ data: record })
+
+    const file = record[CANONICALIZED_FILE]
+    expect(file.statementMap[0]?.end.column).toBe(END_OF_LINE_COLUMN)
+    expect(file.statementMap[1]?.end.column).toBe(9)
+    expect(file.fnMap[0]?.decl.end.column).toBe(END_OF_LINE_COLUMN)
+    expect(file.fnMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
+    expect(file.branchMap[0]?.loc.end.column).toBe(END_OF_LINE_COLUMN)
+    expect(file.branchMap[0]?.locations[0]?.end.column).toBe(END_OF_LINE_COLUMN)
+    expect(file.branchMap[0]?.locations[1]?.end.column).toBe(END_OF_LINE_COLUMN)
+  })
+
+  it('rejects a payload that carries no istanbul coverage data', () => {
+    // Vitest types the reporter hook as `unknown`, so a payload without the
+    // istanbul `data` record must fail the partition loudly rather than leave
+    // every location uncanonicalized.
+    expect(() => {
+      canonicalizeEndOfLineColumns(undefined)
+    }).toThrow(/not an istanbul CoverageMap/)
+    expect(() => {
+      canonicalizeEndOfLineColumns({})
+    }).toThrow(/not an istanbul CoverageMap/)
+  })
+})
+
 describe('coverage partition coordinator', () => {
   const weightedFiles = ['a.spec.ts', 'b.spec.ts', 'c.spec.ts']
   const weightedDurations = new Map([
@@ -285,6 +439,34 @@ describe('coverage partition coordinator', () => {
     ['b.spec.ts', 'process-bound'],
     ['c.spec.ts', 'process-bound'],
   ])
+  it('passes the canonicalizing reporter to every partition', async () => {
+    const root = await temporaryRoot()
+    const commands: CoverageCommand[] = []
+    const runCommand = successfulCommandRecorder(commands)
+    const coordinator = new CoveragePartitionCoordinator({
+      root,
+      partitions: 2,
+      pnpmEntrypoint: '/pnpm.cjs',
+      files: ['a.spec.ts', 'b.spec.ts'],
+      runCommand,
+    })
+
+    await expect(coordinator.run()).resolves.toBe(0)
+
+    // The coordinator passes a root-relative argument and runs every child with
+    // the repository root as its working directory, so the argument must name
+    // the reporter that sits beside this spec. Whether that reporter runs
+    // before the blob write is Vitest's reporter ordering, not a command the
+    // coordinator builds.
+    const specDirectory = dirname(fileURLToPath(import.meta.url))
+    const reporterPath = join(specDirectory, 'coverage-canonical-locations.ts')
+    for (const command of commands.slice(0, 2)) {
+      const argument = command.args.find(candidate => candidate.startsWith('--reporter=') && candidate.endsWith('coverage-canonical-locations.ts'))
+      if (argument === undefined) throw new Error(`${command.label} does not wire the coverage canonicalizer`)
+      expect(resolve(specDirectory, '..', argument.slice('--reporter='.length))).toBe(reporterPath)
+    }
+  })
+
   it('runs every single-worker partition before one merged threshold check', async () => {
     const root = await temporaryRoot()
     const commands: CoverageCommand[] = []

+ 9 - 0
scripts/coverage-partitions.ts

@@ -15,6 +15,14 @@ export const COVERAGE_PARTITION_MODE_ENV = 'DSH_COVERAGE_PARTITION_MODE'
 /** Environment variable overriding instrumented test, polling, and hook timeouts. */
 export const COVERAGE_TEST_TIMEOUT_ENV = 'DSH_COVERAGE_TEST_TIMEOUT_MS'
 
+/**
+ * Reporter that canonicalizes a partition's coverage locations before its blob
+ * is serialized (see scripts/coverage-canonical-locations.ts). Root-relative in
+ * POSIX spelling because every child runs with the repository root as its
+ * working directory on every platform.
+ */
+const CANONICAL_LOCATIONS_REPORTER = './scripts/coverage-canonical-locations.ts'
+
 /** One child command owned by the coverage coordinator. */
 export interface CoverageCommand {
   /** Diagnostic identity. */
@@ -561,6 +569,7 @@ export class CoveragePartitionCoordinator {
       '--reporter=default',
       '--reporter=blob',
       '--reporter=json',
+      `--reporter=${CANONICAL_LOCATIONS_REPORTER}`,
       `--outputFile.blob=${this.relativePath(blobPath)}`,
       `--outputFile.json=${this.relativePath(jsonReportPath)}`,
       `--coverage.reportsDirectory=${this.relativePath(reportsDirectory)}`,

+ 15 - 2
scripts/coverage-uncovered-locations.cjs

@@ -17,6 +17,14 @@
 const path = require('node:path');
 const { ReportBase } = require('istanbul-lib-report');
 
+/**
+ * End column of a location that spans to the end of its start line. Whole-line
+ * statements carry `Infinity` until a partition blob serializes it, which the
+ * partition reporter replaces with this finite column
+ * (scripts/coverage-canonical-locations.ts); both spellings read as line ends.
+ */
+const END_OF_LINE_COLUMN = Number.MAX_SAFE_INTEGER;
+
 /**
  * Editor-convention `line:column` of an istanbul location start (istanbul
  * columns are 0-based; editors and terminal link handlers expect 1-based).
@@ -30,15 +38,20 @@ function usable(loc) {
   return Boolean(loc && loc.start && Number.isFinite(loc.start.line) && loc.start.line >= 1);
 }
 
+/** Whether an end column marks its line's end instead of a measured position. */
+function atLineEnd(column) {
+  return !Number.isFinite(column) || column >= END_OF_LINE_COLUMN;
+}
+
 /**
  * ` (to line:col)` suffix when the range end adds information beyond the
- * start. v8-remapped whole-line statements carry end.column = Infinity; those
+ * start. v8-remapped whole-line statements end at their line end; those
  * degrade to a line-only suffix, or to nothing on a single line.
  */
 function endSuffix(loc) {
   const end = loc.end;
   if (!end || !Number.isFinite(end.line) || end.line < 1) return '';
-  if (!Number.isFinite(end.column)) {
+  if (atLineEnd(end.column)) {
     return end.line === loc.start.line ? '' : ` (to ${end.line})`;
   }
   if (end.line === loc.start.line && end.column === loc.start.column) return '';