DSH / Atlas
2026-08-06implementedprocess

Exact uncovered locations on coverage failure

覆盖率未达标时输出精确未覆盖位置

When the per-file 100% coverage gate fails, vitest emits only file-level error lines (`ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>`) — you learn which file fell short, not which lines. The built-in `text` report does have an Uncovered Line #s column, but it is one giant table over hundreds of files repo-wide: the column truncates at the table width, carries line numbers but no colum

English

Problem

When the per-file 100% coverage gate fails, vitest emits only file-level error lines (ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>) — you learn which file fell short, not which lines. The built-in text report does have an Uncovered Line #s column, but it is one giant table over hundreds of files repo-wide: the column truncates at the table width, carries line numbers but no column numbers, does not distinguish statements from branches from functions, and passing files occupy rows all the same. The net effect is that a red coverage run on CI is not directly actionable; the only way to locate the specific gap is to rerun the html report locally.

Decision

scripts/coverage-uncovered-locations.cjs is a custom istanbul reporter (a ReportBase subclass): for every file below 100%, it emits one self-contained single-line record per uncovered statement, untaken branch path, and uncalled function — <path>:<line>:<col> uncovered <kind> … — directly clickable in terminals and CI logs, and easy to grep. When every file passes, it prints nothing. istanbul report generation runs before threshold validation, so the records land exactly above the existing ERROR lines.

The wiring is a single point: the coverage block in the root vitest.config.ts is the repo's only coverage configuration, shared by the CI lane (run-gates ci-coverage), local test:coverage, and focused runs (--coverage.include). The reporter joins both the CI and local reporter arrays by absolute path (fileURLToPath) — istanbul-reports' create() falls back to a bare require(name) for non-built-in names, and a relative path would resolve against istanbul's own package directory.

Output conventions:

  • istanbul's 0-based column numbers are converted to 1-based (the convention editors and terminal links expect).
  • v8 reports end.column = Infinity for whole-line statements: a span crossing lines degrades to a (to <line>) suffix carrying only the line number, and a single-line span omits the suffix.
  • An implicit branch arm (such as a missing else) may carry no location; the reporter falls back to the branch's own span so the record stays clickable; branch records are annotated with the branch type and path k/n.
  • Records within a file are sorted by line, then column; there is no cap on the count.

Two companion changes: the root package.json adds istanbul-lib-report as a devDependency (under pnpm's strict layout, scripts/ cannot reach nested dependencies); the root workspace's entry/project globs in knip.json gain scripts/**/*.cjs, making the file and its dependencies visible to the hygiene gate.

CJS is a forced shape, and a justified exception to the ESM-everywhere discipline: istanbul loads custom reporters via a bare require() outside the tsx/Vite pipeline, where TypeScript cannot participate; the namespace object require(esm) returns also fails its new Cons(cfg) construction — CommonJS is the only reliable shape.

Alternatives considered

  • Rely on the built-in text report's Uncovered Line #s column. This is precisely the problem as found: one repo-wide table, column-width truncation, line numbers only, no kind distinction, passing files in the same column — not actionable in CI logs.
  • Add a json reporter plus a separate wrapper script that reads coverage-final.json for post-processing after a failure. Feasible in pure ESM/TS, but the wrapper would have to wrap both entry points — package.json's test:coverage and the run-gates gate — changing their command shapes; the custom-reporter route touches one piece of configuration and takes effect at both entry points automatically.
  • Write the reporter in TypeScript/ESM. istanbul's loading mechanism (a bare require outside the pipeline) rules this out, as above; swapping out the loading mechanism for the sake of one report file is out of proportion.

Verification

Local matrix: with a deliberately induced failure, all three record kinds appear and their locations match the planted gaps; a mixed run emits records only for the failing files (files at 100% within the same run stay silent); an all-green run produces zero output and exit code 0. CI evidence: after temporarily planting one unreachable statement/branch/function in clampTimeout, the coverage lane — under the isolated condition of all tests passing (632 files / 10326 cases) with only the threshold failing — printed the 4 records above the ERROR lines; the planted failure is not in the committed tree.

Consequences

  • A red coverage run is self-sufficient: the log gives exact line and column numbers plus the kind of each gap, and rerunning the html report locally to pinpoint it is no longer needed.
  • The cost is one CJS-file discipline exception and one root devDependency; all-green runs produce zero output and add no log noise.
  • A file with zero coverage yields output on the order of its statement count (deliberately uncapped): the gate demands zero gaps, so the full listing is the action list, and vitest's own ERROR lines already provide the per-file summary as a backstop.

中文

问题

per-file 100% 覆盖率门禁失败时,vitest 只输出文件级错误行(ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>)——知道哪个文件没达标,不知道差在哪几行。内置 text 报表虽有 Uncovered Line #s 列,但它是全仓几百个文件的大表:该列按表宽截断、只有行号没有列号、不区分语句/分支/函数,且达标文件同样占行。结果是 CI 上的覆盖率红报无法直接据此处理,定位具体缺口只能本地重跑一遍 html 报表。

决策

scripts/coverage-uncovered-locations.cjs 是一个自定义 istanbul reporter(ReportBase 子类):对每个低于 100% 的文件,为每个未覆盖语句、每条未走的分支路径和每个未调用函数各输出一条自含的单行记录 <path>:<line>:<col> uncovered <kind> …——terminal 与 CI 日志中可直接点击跳转,也便于 grep。全部文件达标时零输出。istanbul 报表生成先于 threshold 校验,因此记录恰好落在既有 ERROR 行上方。

接线是单点的:根 vitest.config.ts 的 coverage 块是全仓唯一覆盖率配置,CI lane(run-gates ci-coverage)、本地 test:coverage 与聚焦跑(--coverage.include)共用它。该 reporter 以绝对路径(fileURLToPath)加入 CI 与本地两个 reporter 数组——istanbul-reports 的 create() 对非内置名回退为裸 require(name),相对路径会按 istanbul 自己的包目录解析。

输出约定:

  • istanbul 的 0 基列号转为 1 基(编辑器与终端链接的约定)。
  • v8 对整行语句给出 end.column = Infinity:跨行时降级为只带行号的 (to <line>) 后缀,单行时省略后缀。
  • 隐式分支臂(如缺少 else 的情况)可能不带位置,reporter 会回退到分支自身的 span,保证记录仍可点击;分支记录标注类型与 path k/n
  • 同文件内记录按行、列排序;不设条数上限。

配套两处:根 package.json 增补 devDependency istanbul-lib-report(pnpm 严格布局下 scripts/ 摸不到嵌套依赖);knip.json 根 workspace 的 entry/project 通配增加 scripts/**/*.cjs,使该文件及其依赖对 hygiene 门禁可见。

CJS 是被迫的形态,也是 ESM-everywhere 纪律的一个有据例外:istanbul 在 tsx/Vite 流水线之外用裸 require() 装载自定义 reporter,TypeScript 无法参与;require(esm) 返回的命名空间对象也过不了它的 new Cons(cfg) 构造,CommonJS 是唯一可靠形态。

考虑过的替代方案

  • 依赖内置 text 报表的 Uncovered Line #s 列。 正是问题现状:全仓大表、列宽截断、只有行号、不分种类、达标文件同列——无法直接根据 CI 日志处理。
  • json reporter,另写包装脚本失败后读 coverage-final.json 后处理。 纯 ESM/TS 可行,但包装脚本必须同时包住 package.jsontest:coverage 与 run-gates 的 gate 两个入口,命令形状随之改变;自定义 reporter 路线只动一处配置,两个入口自动生效。
  • 用 TypeScript/ESM 写 reporter。 istanbul 的装载机制(流水线外裸 require)决定了不可行,见上;为一个报表文件把装载机制换掉,代价不成比例。

验证

本地矩阵:故意制造未达标时三类记录齐全、位置与埋点一致;混合运行只输出未达标文件(同跑内 100% 的文件静默);全绿跑零输出、退出码 0。CI 实证:临时在 clampTimeout 埋入一处不可达语句/分支/函数后,coverage lane 在全部测试通过(632 文件 / 10326 用例)、仅 threshold 失败的隔离条件下,把 4 条记录打印在 ERROR 行上方;埋入的失败并不在已提交的代码树中。

后果

  • 覆盖率红报自足:日志直接给出精确行列号与缺口种类,不再需要本地重跑 html 报表定位。
  • 代价是一个 CJS 文件的纪律例外与一个根 devDependency;全绿运行零输出,不增加日志噪音。
  • 整文件零覆盖时输出条数与该文件语句数同阶(刻意不设上限):门禁要求零缺口,全量列出即是行动清单,vitest 自身的 ERROR 行已按文件汇总兜底。