Explorar o código

fix(coverage): fail loud when the canonicalizer gets no coverage data

Vitest types `Reporter.onCoverage` as `unknown`, so a payload that carries no
istanbul `data` record now fails the partition instead of silently leaving
every location uncanonicalized; a spec case pins that rejection. The module
docs keep the contract and link the Agent Note for the mechanism, the
partition spec names what it asserts (the reporter argument, not Vitest hook
ordering), and the notes correct the `Infinity` origin to ast-v8-to-istanbul,
record the reporter-ordering dependency, and name the reporter each partition
child carries.
Tianyi Cui hai 1 semana
pai
achega
10930a0205

+ 2 - 2
.agents/notes/implemented/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.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/bug-fix/2026-09-13-partitioned-coverage-location-canonicalization.md
-2026-09-13-partitioned-coverage-location-canonicalization.md: 69afaa5244f3d810bad01079e59a9ce64dfe9f93
-2026-09-13-partitioned-coverage-location-canonicalization.zh.md: 4f32c229af1db01bdc29ea800494610138d1b5d6
+2026-09-13-partitioned-coverage-location-canonicalization.md: 54e5aaefe9551d1357f1c60201212873a1c7aef7
+2026-09-13-partitioned-coverage-location-canonicalization.zh.md: 1aa4b8de844a1f0e544d2bbb223641dbbfeff3da

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

@@ -12,7 +12,7 @@ A source file reaches the merged report through one statement map per Vite envir
 
 ## 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.
+[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.
 
@@ -20,11 +20,11 @@ A source file reaches the merged report through one statement map per Vite envir
 
 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. V8 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.
+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, and a third pins the canonicalizing reporter on every partition command.
+`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
 
@@ -40,4 +40,4 @@ That reconciliation runs on locations `getLoc()` accepts, which requires numeric
 
 ## 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.
+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.

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

@@ -12,7 +12,7 @@ Status: implemented
 
 ## 决策
 
-[scripts/coverage-partitions.ts](../../../../scripts/coverage-partitions.ts) 为每个分区传入 `--reporter=./scripts/coverage-canonical-locations.ts`;该报告器的 `onCoverage` 钩子把当次运行覆盖率映射中所有非有限的结束列改写为 `Number.MAX_SAFE_INTEGER`。该列保留“位置结束于所在行行尾”的含义,序列化后仍是数字,并且在每个 blob 中生成相同的键,因此合并命令会像进程内合并那样调和各环境特有的写法。逐文件 100% 门禁对 `coverage.include` 命中的每个文件保持完整强度:规范化只会通过 istanbul 的包含规则增加命中,绝不会把语句移出报告。
+[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)协调器拥有该报告器所加入的分区与合并命令,并保留其唯一一次合并阈值判定。
 
@@ -20,11 +20,11 @@ Status: implemented
 
 节点侧套件把源文件映射到 `ssr` 环境,`@vitest-environment jsdom` 套件把它映射到 `client` 环境。基于 AST 的 V8 重映射器按它在转换后代码中找到的节点定位语句,因此同一条声明会以两种写法进入合并映射:ssr 转换给出其声明标识符的位置,client 转换给出其嵌套调用表达式的位置。istanbul-lib-coverage 会把最窄包含范围的命中计入其他记录都未命名的条目,从而覆盖 client 记录引入的那种写法。
 
-该调和只作用于 `getLoc()` 接受的位置,这要求行列值都是数字。V8 把整行语句的结束列标为 `Infinity`;分区 blob 会把 `Infinity` 序列化为 `null`,于是合并命令拿到无法比较的位置,并把仅存在于 client 的写法保留为额外的未命中语句。同样的记录在单个进程内合并时 `Infinity` 得以保留,因此不会报出该语句。
+该调和只作用于 `getLoc()` 接受的位置,这要求行列值都是数字。ast-v8-to-istanbul 把整行语句的结束列标为 `Infinity`;分区 blob 会把 `Infinity` 序列化为 `null`,于是合并命令拿到无法比较的位置,并把仅存在于 client 的写法保留为额外的未命中语句。同样的记录在单个进程内合并时 `Infinity` 得以保留,因此不会报出该语句。
 
 ## 验证
 
-`scripts/coverage-partitions.spec.ts` 让两条以两种写法表示同一语句的记录经过 blob 实际执行的 JSON 跳转后再合并,断言规范化后的合并没有未覆盖语句,而未经规范化的合并会报出该幻影语句。第二个用例固定语句、函数与分支位置的规范化,第三个用例固定每个分区命令都带上该规范化报告器。
+`scripts/coverage-partitions.spec.ts` 让两条以两种写法表示同一语句的记录经过 blob 实际执行的 JSON 跳转后再合并,断言规范化后的合并没有未覆盖语句,而未经规范化的合并会报出该幻影语句。第二个用例固定语句、函数与分支位置的规范化,第三个用例固定每个分区命令都带上该规范化报告器,第四个用例固定对不含覆盖率数据的载荷的显式拒绝
 
 ## 曾考虑的替代方案
 
@@ -40,4 +40,4 @@ Status: implemented
 
 ## 后果
 
-分区运行对跨环境文件给出与单进程相同的归因,因此门禁在不豁免任何文件的前提下保持逐文件 100%。规范化只在分区内部运行,未分区运行及其报告保持原样。blob 在行尾位置携带一个有限哨兵列,分区报告器与未覆盖位置报告器都把该哨兵命名为行尾约定。
+分区运行对跨环境文件给出与单进程相同的归因,因此门禁在不豁免任何文件的前提下保持逐文件 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`。只有这条合并命令应用仓库的逐文件语句、分支、函数与行阈值,因此系统不会拿有意不完整的测试清单单独判定任一分区。
 

+ 20 - 22
scripts/coverage-canonical-locations.ts

@@ -2,24 +2,18 @@
  * Canonicalize coverage locations inside a coverage partition, before its
  * blob is written.
  *
- * Vitest remaps V8 coverage separately per Vite environment, so one source file
- * can carry two statement maps in a single run: a node suite maps it through the
- * ssr environment, a `@vitest-environment jsdom` suite maps it through the
- * client environment, and ast-v8-to-istanbul spells the same statement
- * differently in each map (a whole-line statement ends at column `Infinity` in
- * the ssr map, at a nested expression column in the client map).
- *
- * istanbul-lib-coverage reconciles such overlapping ranges while merging, but
- * only while a location carries numeric line and column values: `Infinity`
- * survives an in-process merge, not the JSON serialization of a partition blob,
- * which turns it into `null`. Without that reconciliation the merge keeps the
- * client-only spelling as an extra, unhit statement, so a file whose every
- * statement ran still fails the per-file 100% gate.
- *
- * Replacing the non-finite end column with {@link END_OF_LINE_COLUMN} keeps the
- * line-end meaning, survives serialization, and keys identically in every blob,
- * so the merged report attributes the coverage one process would have reported.
+ * 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
  */
 
@@ -61,13 +55,17 @@ function canonicalizeEntry(entry: unknown): void {
 
 /**
  * Replace every non-finite end column of an istanbul coverage map in place.
- * @param coverageMap - istanbul `CoverageMap` whose `data` holds per-file coverage;
- * any value without that structure is left untouched.
+ * @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 {
-  if (!isRecord(coverageMap)) return
-  const files = coverageMap['data']
-  if (!isRecord(files)) return
+  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.

+ 21 - 6
scripts/coverage-partitions.spec.ts

@@ -413,6 +413,18 @@ describe('coverage location canonicalization', () => {
     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', () => {
@@ -427,7 +439,7 @@ describe('coverage partition coordinator', () => {
     ['b.spec.ts', 'process-bound'],
     ['c.spec.ts', 'process-bound'],
   ])
-  it('canonicalizes each partition before its blob is written', async () => {
+  it('passes the canonicalizing reporter to every partition', async () => {
     const root = await temporaryRoot()
     const commands: CoverageCommand[] = []
     const runCommand = successfulCommandRecorder(commands)
@@ -441,14 +453,17 @@ describe('coverage partition coordinator', () => {
 
     await expect(coordinator.run()).resolves.toBe(0)
 
-    // Every partition serializes its coverage map into a blob, so every
-    // partition must name the reporter that canonicalizes it first.
-    const reporterPath = join(dirname(fileURLToPath(import.meta.url)), 'coverage-canonical-locations.ts')
+    // 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`)
-      // The child runs from the repository root, so the reporter resolves there.
-      expect(resolve(argument.slice('--reporter='.length))).toBe(reporterPath)
+      expect(resolve(specDirectory, '..', argument.slice('--reporter='.length))).toBe(reporterPath)
     }
   })