将《代码审计 → 上线 完整交付方案》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 全绿
247 lines
8.2 KiB
JavaScript
247 lines
8.2 KiB
JavaScript
#!/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✓ 覆盖率门禁通过');
|