DeepSeek Harness 覆盖率门禁实战:用自定义 istanbul reporter 输出精确未覆盖位置
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
per-file 100% 覆盖率门禁是 DeepSeek Harness(everything-is-a-plugin 架构的 Agent 编排框架)持续集成的硬性底线,但门禁失败时 vitest 只报文件、不报行号,CI 红报几乎无法直接处理。本指南以仓库内一条已实现(implemented)的工程决策笔记为主线,完整讲解其自定义 istanbul reporter(scripts/coverage-uncovered-locations.cjs)从问题定位、方案选型、接线方式、输出约定到验证方法的全部细节,读完你既能直接复用该工具定位覆盖率缺口,也能掌握在 vitest + istanbul 生态中扩展自定义报表的完整套路。
问题:门禁知道"哪个文件没达标",却不知道"差在哪几行"
DeepSeek Harness 在根 vitest.config.ts 的 coverage 块中对全部packages/*/*/src/**/*.{ts,tsx}启用了逐文件(perFile)100% 门槛,语句、分支、函数、行四类指标全部要求 100%:
thresholds: coveragePartitionMode ? undefined : { perFile: true, statements: 100, branches: 100, functions: 100, lines: 100, },per-file 语义很严格:一个覆盖良好的大文件不能补贴一个近乎空白的小文件。然而当某个文件触发门槛失败时,vitest 输出的只有文件级错误行:
ERROR: Coverage for lines (…) does not meet global threshold (100%) for <file>这条信息能告诉你哪个文件掉了链子,却不能告诉你具体差在哪些行。内置的text报表虽然有 "Uncovered Line #s" 列,但它是覆盖全仓几百个文件的巨型表格,存在四个致命缺陷:
- 该列按表宽截断,长路径和长行号直接看不全;
- 只给行号、不给列号,大文件内仍难以精确定位;
- 不区分语句(statement)、分支(branch)、函数(function)三类缺口;
- 达标文件同样占行,噪音巨大。
最终结果正如笔记所描述:CI 上的覆盖率红报无法直接据此处理,唯一的定位办法是在本地重跑一遍 html 报表。这正是本方案要消除的痛点。
决策:一个自定义 istanbul reporter,输出可直接点击的单行记录
核心方案是新增 scripts/coverage-uncovered-locations.cjs——一个继承istanbul-lib-report中ReportBase的自定义 reporter。它的工作方式如下:
- 对每个低于 100%的文件,为每一个未覆盖语句、每一条未走到的分支路径、每一个未被调用的函数各输出一条自含的单行记录;
- 记录格式为
<path>:<line>:<col> uncovered <kind> …,在终端与 CI 日志中可直接点击跳转,也便于 grep 过滤; - 当所有文件全部达标时零输出,绿色运行保持安静;
- istanbul 的报表生成发生在 threshold 校验之前,因此这些记录恰好落在既有 ERROR 行的正上方,阅读顺序自然顺畅。
源码级拆解:三类记录如何生成
reporter 的核心逻辑在onDetail(node)回调中,对每个文件分别遍历三类映射表:
for (const id of Object.keys(fc.statementMap)) { if (fc.s[id] !== 0) continue; // 已覆盖(计数非 0)的语句跳过 const loc = fc.statementMap[id]; if (!usable(loc)) continue; add(loc, `${rel}:${pos(loc)} uncovered statement${endSuffix(loc)}`); }- 语句:遍历
statementMap,凡fc.s[id](执行计数)为 0 即视为未覆盖; - 函数:遍历
fnMap,fc.f[id]为 0 即为未调用,优先使用fn.decl(声明位置),不可用时回退到fn.loc,并附带函数名便于识别:const name = fn.name ? ` ${fn.name}` : ''; add(loc, `${rel}:${pos(loc)} uncovered function${name}`); - 分支:遍历
branchMap,对每条分支路径逐一检查计数,未走到的路径输出uncovered branch (<type>, path k/n),标注分支类型(如if、switch)与路径序号k/n。
onEnd()负责汇总输出:先打印一行Uncovered locations (per-file 100% gate): <count>作为小节标题,再逐行打印记录。若records为空则整体静默——这就是"全绿零输出"的实现机制。
接线:全仓唯一的覆盖率配置点,CI 与本地共用
该方案的关键工程决策是单点接线。根 vitest.config.ts 的 coverage 块是全仓唯一的覆盖率配置,被三类运行方式共同消费:
- CI lane:
check:ci:coverage→tsx scripts/run-gates.ts ci-coverage(见根 package.json),内部由coverageGates()(scripts/run-gates.ts)执行pnpm exec vitest run --coverage; - 本地命令:
test:coverage→vitest run --coverage(根 package.json); - 聚焦运行:通过
--coverage.include只对指定文件测覆盖率。
reporter 以绝对路径同时加入 CI 与本地两个 reporter 数组:
const uncoveredLocationsReporter = fileURLToPath(new URL('./scripts/coverage-uncovered-locations.cjs', import.meta.url)) // ... reporter: coveragePartitionMode ? [] : process.env.CI ? ['text', uncoveredLocationsReporter] : ['text', 'html', uncoveredLocationsReporter],这里有一个容易被忽视的坑:必须用fileURLToPath转成绝对路径。原因在笔记中有明确说明——istanbul-reports 的create()对非内置 reporter 名会回退为裸require(name),如果传相对路径,会按照istanbul 自己的包目录去解析,必然找不到文件。用绝对路径才能确保解析到仓库内的脚本。
另外注意,CI 环境(process.env.CI)下 reporter 数组是['text', uncoveredLocationsReporter],本地则是['text', 'html', uncoveredLocationsReporter]——html 报表只在本地生成,CI 只依赖 text 与自定义输出。
输出约定:五条细节保证日志可用性
reporter 的输出细节并非随意为之,每条约定都对应一个真实的工具链问题(全部可在 scripts/coverage-uncovered-locations.cjs 源码中逐一核对):
0 基列号转 1 基:istanbul 内部列号从 0 开始,而编辑器与终端链接处理约定是 1 基。
pos()函数统一执行loc.start.column + 1:function pos(loc) { return `${loc.start.line}:${loc.start.column + 1}`; }v8 的
end.column = Infinity降级:v8 对整行语句给出的结束列号是Infinity(无实际列语义)。endSuffix()中检测到非有限列号时:跨行的跨度降级为只带行号的(to <line>)后缀,单行跨度则直接省略后缀;只有结束位置确实携带额外信息时才输出完整的(to <line>:<col>)。隐式分支臂的位置回退:分支的隐式臂(例如缺少
else的情况)可能不带位置信息,此时 reporter 回退到分支自身的 span,保证记录仍然可点击跳转,同时保留path k/n标注。记录排序:同文件内按行、再按列排序(
items.sort((a, b) => a.line - b.line || a.column - b.column)),输出次序稳定可预期。不设条数上限:整文件零覆盖时,输出条数与文件语句数同阶。这是刻意为之——门禁要求零缺口,全量列出本身就是"行动清单";vitest 自身的 ERROR 行已按文件汇总作为兜底,不会因长输出丢失文件级摘要。
为什么必须是 CJS:ESM-everywhere 纪律的一个有据例外
DeepSeek Harness 整体遵循 ESM-everywhere 的工程纪律,但这个 reporter 文件被迫使用 CommonJS,这是有明确技术依据的例外,而非偷懒:
- istanbul 在 tsx/Vite 流水线之外用裸
require()装载自定义 reporter,TypeScript 在这一环节完全无法参与编译; require(esm)返回的是命名空间对象,无法通过 istanbul 的new Cons(cfg)构造调用,因此 ESM 同样不可行;- 结论:CommonJS 是唯一可靠形态。
脚本开头的大段注释也如实记录了这一点(CommonJS by requirement: istanbul-reports loads custom reporters with a bare require() outside the tsx/ESM pipeline),把"为什么破坏纪律"写成了代码级文档。
配套改动:依赖与 hygiene 门禁可见性
方案还附带两处配套改动,缺一不可:
根 package.json 新增 devDependency
istanbul-lib-report:在 pnpm 的严格依赖布局下,scripts/目录摸不到嵌套依赖(无法依赖 vitest 传递引入的 istanbul 包),必须显式声明为根 devDependency。knip.json 的 entry/project 通配增加
scripts/**/*.cjs:使该 CJS 文件及其依赖对仓库的 hygiene 门禁(未使用依赖/未导出符号检查)可见,避免新文件被误报为死代码。
考虑过的替代方案:为什么最终选自定义 reporter
笔记明确记录了三条被否决的路线,理解它们能帮你判断何时该复用本方案:
- 依赖内置
text报表的 Uncovered Line #s 列:这正是问题现状本身——全仓大表、列宽截断、只有行号、不分缺口种类、达标文件同列,无法在 CI 日志中直接处理,等于没解决。 - 加
jsonreporter + 包装脚本后处理coverage-final.json:纯 ESM/TS 可行,但包装脚本必须同时包住package.json的test:coverage与 run-gates 的 gate两个入口,命令形状随之改变;而自定义 reporter 路线只动一处配置,两个入口自动生效——这正是单点接线的收益。 - 用 TypeScript/ESM 写 reporter:被 istanbul 的裸
require()装载机制直接否决(见上文);为一个报表文件去替换 istanbul 的装载机制,代价不成比例。
验证:本地矩阵 + CI 实证
方案的验证采用"本地矩阵 + CI 实证"双轨:
- 本地矩阵:故意制造未达标时,三类记录(语句/分支/函数)齐全、位置与埋点一致;混合运行只输出未达标文件,同一次运行中 100% 的文件保持静默;全绿运行零输出、退出码 0。
- CI 实证:临时在
clampTimeout埋入一处不可达语句/分支/函数后,coverage lane 在"全部测试通过(632 文件 / 10326 用例)、仅 threshold 失败"的隔离条件下,把 4 条记录打印在 ERROR 行上方;埋入的失败代码并未进入已提交的代码树,验证了工具在真实门禁下的表现。
后果与边界
- 覆盖率红报自足:日志直接给出精确行列号与缺口种类,不再需要本地重跑 html 报表定位——这是本方案最大的收益。
- 代价可控:一个 CJS 文件的纪律例外 + 一个根 devDependency;全绿运行零输出,不增加任何日志噪音。
- 边界明确:整文件零覆盖时输出与语句数同阶(刻意不设上限),门禁要求零缺口,全量列出即行动清单。
如需进一步了解项目覆盖率策略的全局设计(包括豁免机制、分区模式、Windows 平台排除等),可继续阅读根 vitest.config.ts 的 coverage 块与 docs/testing.md;若要在自己的 vitest 项目中复用该方案,只需复制coverage-uncovered-locations.cjs、按fileURLToPath绝对路径接线、并显式声明istanbul-lib-report依赖即可。
【免费下载链接】deepseek-harnessDeepSeek Harness: Everything is a Plugin.项目地址: https://gitcode.com/gh_mirrors/de/deepseek-harness
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考