Files
nodeagent/scripts/ci/check-coverage.mjs
T
jkooe 2bf45fdf98 feat(pipeline): 交付流水线规范落地 —— 门禁 + LKG 回滚锚点
将《代码审计 → 上线 完整交付方案》v1.0 适配到 nodeagent,产出
docs/DELIVERY-PIPELINE.md,并落地其中可执行的部分。

## 为什么要适配而不是照搬
原方案面向 Web/容器服务(有服务端、有镜像、有多环境)。nodeagent 是
跑在用户机器上的跨机接管框架,无服务端、无容器、无多环境。照搬会产生
「跑得很好但守不住任何东西」的假门禁。故每个阶段给三档判定:
适用 / 改造后适用 / 不适用(但必须给替代,不允许只写 N/A)。

关键替换:
- 容器镜像扫描 → 交付物 PE 架构断言(arm64 包误装 x64 node.exe 的真实事故)
- DAST → 协议健壮性测试(本项目对外输入面是 WebSocket RPC,非 HTTP)
- dev/staging/prod → 真机矩阵(Win x64/arm64、macOS、Linux)
- 金丝雀 → 设备分批自更新(先一台试点,latest.json 灰度)
- Prometheus → system.metrics(闭环率/P95/拦截率)+ 发布后复核清单

## 本次落地
1. 覆盖率门禁 scripts/ci/check-coverage.mjs
   - 全量**只观测不阻断**(实测基线 66.9%,照搬 80% 全量不现实)
   - 变更文件行覆盖率 ≥70% 阻断;单测 import 的是 dist/,需 src→dist 映射
   - 无覆盖数据的变更文件不阻断,单列供人工判断(算 0% 会让重构无法合入)
2. 提交信息校验 scripts/ci/check-commit-msg.mjs
   - 项目实际已在用 Conventional Commits,但无强制 → 变成会红的门禁
   - 不引 commitlint+husky:本项目只需类型白名单,几十行即可且 CI 本地都能跑
3. .github/workflows/quality-gate.yml:gitleaks + pnpm audit + typecheck + 覆盖率 + 提交规范
4. CI artifact retention-days 30 → 7(公开可下载,留存越久暴露面越大)
5. LKG(最后已知可用版本)apps/agent/src/lkg.ts
   - 只在**启动成功后**才写,不在更新写完时写(否则等于把未验证版本当已知可用)
   - 暴露为 system.info.lkg,matches=false 表示当前版本尚未被证明能启动
   - 控制端据此暂缓向其余设备放量

## 刻意不做的事:自动回滚
远程设备上无法区分「更新坏了」与「网络抖动」;自动回滚可能形成回滚循环;
被控端自作主张改自己会让控制端失去掌控。故 LKG 只提供确定的回滚目标,
决策权留给控制端。已在文档中写明取舍理由。

## 实测证据
- 覆盖率门禁三条故障注入均 exit=1(阈值100 / 全量99 / lcov缺失),正常 exit=0
- 提交规范反例 3 类均红,Merge 与无 scope 正确豁免,历史 20 条提交一次通过
- LKG 新增 8 条单测(含篡改 hash 必须检出 matches=false)
- 既有契约测试在新增 lkg 字段后立刻报红(SystemInfo 缺声明),补接口后转绿
  —— 这本身是门禁有效的实证
- 全量单测 341 → 349 全绿
2026-10-17 03:13:18 +08:00

247 lines
8.2 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env node
/**
* 覆盖率门禁(交付流水线 §2 / §8)。
*
* ## 为什么自己解析 lcov 而不装 c8/nyc
* 单测用的是 Node 内置 `node --test --experimental-test-coverage`,它原生出 lcov。
* 再引一套覆盖率工具只为「读一下阈值」属于重复建设 —— 本项目铁律之一就是
* 「重复功能优先引用已有模块,不新建冗余内容」。
*
* ## 口径(重要,别照搬通用 80%)
* 原方案(面向 Web 新项目)要求全量 ≥80%。本项目存量 341 个单测集中在
* 协议/编码/安全守卫,而 GUI 注入、平台脚本等模块**无法在 CI 上无头验证**。
* 直接套 80% 全量阈值只会逼出两种结果:长期 `--skip`,或为 GUI 代码写假断言。
*
* 故采用:
* - **全量:只观测,不阻断**(出数字,建基线)
* - **变更文件:行覆盖率 ≥ THRESHOLD 才阻断**(默认 70)
*
* ## 一个必须说清的映射
* 单测 import 的是 `dist/*.js`(编译产物),不是 `src/*.ts`。
* 所以「本次改了哪个源文件」要映射成「dist 里的哪个文件」才能查到覆盖率:
* apps/agent/src/audit.ts → apps/agent/dist/audit.js
* 规则:`/src/` → `/dist/`,`.ts` → `.js`。
*
* ## 无覆盖数据怎么办
* 改动的源文件若在 lcov 里查不到对应条目(例如纯类型改动、文档、或压根没被测试碰过),
* **不阻断**,只列进「无覆盖数据」清单。理由:把它算作 0% 会让任何重构都无法合入;
* 而如实列出来,比一个会被绕过的门禁更有用。
*/
import { readFileSync, existsSync } from 'node:fs';
import { execFileSync } from 'node:child_process';
import { relative, resolve } from 'node:path';
const REPO = resolve(process.cwd());
function fail(msg) {
console.error(`\n✗ ${msg}`);
process.exit(1);
}
// ---------------- 参数 ----------------
function parseArgs(argv) {
const out = {
lcov: 'coverage/lcov.info',
threshold: 70,
base: '',
// 全量不达标是否阻断(默认否:只观测)
enforceTotal: false,
totalThreshold: 80,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === '--lcov') out.lcov = argv[++i];
else if (a === '--threshold') out.threshold = Number(argv[++i]);
else if (a === '--total-threshold') out.totalThreshold = Number(argv[++i]);
else if (a === '--base') out.base = argv[++i];
else if (a === '--enforce-total') out.enforceTotal = true;
else if (a === '--help' || a === '-h') {
console.log(
`用法: node scripts/ci/check-coverage.mjs [选项]\n` +
` --lcov <path> lcov 路径(默认 coverage/lcov.info)\n` +
` --threshold <n> 变更文件行覆盖率阈值(默认 70)\n` +
` --base <git-ref> 比对基线(如 origin/main);为空则只看全量\n` +
` --enforce-total 全量不达标也阻断(默认只观测)\n` +
` --total-threshold <n> 全量阈值(默认 80,仅在 --enforce-total 时生效)`,
);
process.exit(0);
}
}
return out;
}
// ---------------- lcov 解析 ----------------
/**
* 解析 lcov,返回「项目自有文件」的覆盖率。
*
* 过滤掉三类噪声:
* 1. node_modules
* 2. 仓库外路径(运行时注入的 shim,实测会混进 WorkBuddy/Application 路径)
* 3. .test.mjs 自身(测试文件不该计入被测代码)
*/
function parseLcov(text) {
const out = new Map(); // repoRelPath -> {found, hit}
let cur = null;
let found = 0;
let hit = 0;
const flush = () => {
if (!cur) return;
const rel = toRepoRelative(cur);
if (rel && isProjectFile(rel)) {
const prev = out.get(rel);
if (prev) {
prev.found += found;
prev.hit += hit;
} else {
out.set(rel, { found, hit });
}
}
cur = null;
found = 0;
hit = 0;
};
for (const raw of text.split('\n')) {
const line = raw.trimEnd();
if (line.startsWith('SF:')) {
flush();
cur = line.slice(3).trim();
} else if (line === 'end_of_record') {
flush();
} else if (cur) {
if (line.startsWith('LF:')) found = Number(line.slice(3)) || 0;
else if (line.startsWith('LH:')) hit = Number(line.slice(3)) || 0;
}
}
flush();
return out;
}
function toRepoRelative(absPath) {
const p = resolve(absPath);
if (!p.startsWith(REPO)) return null;
const rel = relative(REPO, p);
if (rel.startsWith('..')) return null;
return rel;
}
function isProjectFile(rel) {
if (rel.includes('node_modules/')) return false;
if (rel.startsWith('node_modules/')) return false;
// 只统计本仓库的 apps / packages / scripts
if (!/^(apps|packages|scripts)\//.test(rel)) return false;
// 测试自身不计入
if (/\.test\.mjs$/.test(rel)) return false;
return true;
}
/** 源文件(src/*.ts)→ 单测实际 import 的产物路径(dist/*.js)。 */
function mapSourceToArtifact(srcRel) {
if (srcRel.includes('/src/')) return srcRel.replace('/src/', '/dist/').replace(/\.ts$/, '.js');
return srcRel;
}
// ---------------- 变更文件 ----------------
function changedFiles(base) {
if (!base) return [];
const range = `${base}...HEAD`;
let out = '';
try {
out = execFileSync('git', ['diff', '--name-only', range], { encoding: 'utf8' });
} catch {
// 基线不可达(例如浅克隆)→ 退化为与 HEAD~1 比对
try {
out = execFileSync('git', ['diff', '--name-only', 'HEAD~1', 'HEAD'], { encoding: 'utf8' });
} catch {
return [];
}
}
return out
.split('\n')
.map((s) => s.trim())
.filter(Boolean);
}
// ---------------- 主流程 ----------------
const args = parseArgs(process.argv.slice(2));
if (!existsSync(args.lcov)) {
fail(`找不到 lcov 文件: ${args.lcov}\n 请先跑:node --test --experimental-test-coverage --test-reporter=lcov --test-reporter-destination=${args.lcov} "tests/unit/**/*.test.mjs"`);
}
const cov = parseLcov(readFileSync(args.lcov, 'utf8'));
if (cov.size === 0) {
fail(`lcov 里没有任何项目文件(${args.lcov})—— 覆盖率采集可能失败,请检查是否加了 --experimental-test-coverage`);
}
// --- 全量 ---
let totalFound = 0;
let totalHit = 0;
for (const v of cov.values()) {
totalFound += v.found;
totalHit += v.hit;
}
const totalPct = totalFound > 0 ? (totalHit / totalFound) * 100 : 0;
console.log(`全量覆盖率(观测) : ${totalPct.toFixed(1)}% (${totalHit}/${totalFound} 行, ${cov.size} 个文件)`);
// --- 变更文件 ---
const changed = changedFiles(args.base);
let blocked = false;
const noData = [];
if (changed.length > 0) {
const candidates = new Map(); // artifactRel -> srcRel
for (const f of changed) {
if (!/^(apps|packages|scripts)\//.test(f)) continue;
if (/\.(md|json|ya?ml|txt)$/.test(f)) continue; // 文档/配置不参与
const art = mapSourceToArtifact(f);
candidates.set(art, f);
}
const rows = [];
for (const [art, src] of candidates) {
const v = cov.get(art);
if (!v) {
noData.push(src);
continue;
}
const pct = v.found > 0 ? (v.hit / v.found) * 100 : 100;
rows.push({ src, art, pct, hit: v.hit, found: v.found });
}
rows.sort((a, b) => a.pct - b.pct);
console.log(`\n变更文件覆盖率(阈值 ${args.threshold}%,阻断):`);
if (rows.length === 0) console.log(' (无具备覆盖数据的变更文件)');
for (const r of rows) {
const mark = r.pct < args.threshold ? '✗' : '✓';
console.log(` ${mark} ${r.pct.toFixed(1).padStart(5)}% ${r.src} (${r.hit}/${r.found} 行)`);
if (r.pct < args.threshold) blocked = true;
}
if (noData.length > 0) {
console.log(`\n无覆盖数据的变更文件(不阻断,供人工判断):`);
for (const f of noData) console.log(` - ${f}`);
}
} else {
console.log('\n(未提供 --base,跳过变更文件判定)');
}
if (args.enforceTotal && totalPct < args.totalThreshold) {
console.error(`\n✗ 全量覆盖率 ${totalPct.toFixed(1)}% < 阈值 ${args.totalThreshold}%`);
blocked = true;
}
if (blocked) {
console.error('\n✗ 覆盖率门禁未通过');
process.exit(1);
}
console.log('\n✓ 覆盖率门禁通过');