Canonicalize partition coverage locations before the blob merge
在 blob 合并前规范化分区覆盖率位置
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 b
English
Problem
The coverage gate reported 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 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 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 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.
中文
问题
覆盖率门禁把 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 为每个分区传入 --reporter=./scripts/coverage-canonical-locations.ts;该报告器的 onCoverage 钩子把当次运行覆盖率映射中所有非有限的结束列改写为 Number.MAX_SAFE_INTEGER。该列保留“位置结束于所在行行尾”的含义,序列化后仍是数字,并且在每个 blob 中生成相同的键,因此合并命令会像进程内合并那样调和各环境特有的写法。逐文件 100% 门禁对 coverage.include 命中的每个文件保持完整强度:规范化只会通过 istanbul 的包含规则增加命中,绝不会把语句移出报告。载荷若不含 istanbul 的 data 记录,分区会直接失败,而不是让所有位置保持未规范化。
scripts/coverage-uncovered-locations.cjs 把同一列读作行尾,因此无论是否经过规范化,未覆盖记录打印的 path:line:col 都相同。单 job 分区覆盖率协调器拥有该报告器所加入的分区与合并命令,并保留其唯一一次合并阈值判定。
跨 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 后若次序改变,最先表现为幻影语句复现。