- README:数字刷新为实测(52 能力 · 42 MCP · 467 单测 · 50 e2e · 23 Rust); 补 na_audit_l2 工具表项、看门狗三端接入段;删「控制台安装包只作为 CI artifact」过期行(9f95560 起已随 Release 发布)。 - CHANGELOG:[未发布] 定稿 v2.1.0 全部主题(rollback / 看门狗 / na-gui 清理 / e2e 与文档收口),与 A 线已回填段共存无重复。 - DEVELOPMENT:新增 §7.5 selfupdate 代理出网机制(NA_PROXY→环境变量→ 系统代理、PAC/socks5 明确报错、回环恒直连、计划任务环境变量告诫); §7.4 补 rollback/看门狗双通道分工表与「为什么运行期不做自动回滚」 三问引述。 - ROADMAP:新增第十节 —— v2.1.0 已落地项划掉、P2 登记(控制台自更新 v2.2.0 / Linux auditd / macOS ESF / macOS 安装器 / guard 日志轮换)。 - tests/unit/docs-numbers.test.mjs(新,6 例):README 五个数字与可 复现实测口径钉死(manifest 条目 / defs.ts 工具 / node --test 实测 / e2e 源码 / Rust 属性),「增能力不改 README」即红。单测数实测经 NODE_TEST_CONTEXT 剥离 + NA_DOCS_NUMBERS_CHILD 递归防护;注入故障 (52→53)实测 2 处守卫变红、恢复全绿。
128 lines
5.8 KiB
JavaScript
128 lines
5.8 KiB
JavaScript
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { execFileSync } from 'node:child_process';
|
||
import { readFileSync, readdirSync } from 'node:fs';
|
||
import { join } from 'node:path';
|
||
|
||
/**
|
||
* 文档数字守卫(ARCH B-P1-3 / T05)。
|
||
*
|
||
* ## 为什么需要
|
||
* README 的能力数 / MCP 工具数 / 单测数 / e2e 数是「会腐烂的数字」:
|
||
* 每次加能力、加工具、加测试都可能忘同步。腐烂的数字比没有数字更糟 ——
|
||
* 它给人虚假的信心(「README 说 52 项,肯定全测过」)。
|
||
* 本守卫把 README 的数字与**可复现的实测口径**钉在一起:
|
||
* 不一致即红,逼使「改代码必改文档」。
|
||
*
|
||
* ## 口径(全部在 Mac/Linux 上可复现,不依赖 Windows)
|
||
* - 能力数:packages/protocol/src/capabilities/manifest/*.ts 的 `name: CapabilityNames.` 计数
|
||
* - MCP 工具数:apps/mcp/src/tools/defs.ts 的 `name: 'na_` 计数
|
||
* - 单元测试数:`node --test tests/unit/**\/*.test.mjs` 的汇总行实测
|
||
* - e2e 数:tests/e2e/{run,windows}.mjs 的 `await test(` 计数之和
|
||
* - Rust 数:apps/console/src-tauri/src/*.rs 的 `#[test]` / `#[tokio::test]` 计数
|
||
*
|
||
* ## 递归防护
|
||
* 单测数实测要 spawn 一个全量 node --test 子进程;该子进程也会跑到本文件。
|
||
* 用 NA_DOCS_NUMBERS_CHILD=1 让子进程内的本用例跳过 spawn(否则无限递归),
|
||
* 子进程的汇总行仍包含本用例本身 —— README 数字即含守卫自身,自洽。
|
||
*/
|
||
|
||
const root = join(import.meta.dirname, '../..');
|
||
const readme = readFileSync(join(root, 'README.md'), 'utf8');
|
||
const isChild = !!process.env['NA_DOCS_NUMBERS_CHILD'];
|
||
|
||
/**
|
||
* README 数字行:**52 项能力** · **42 个 MCP 工具** · **467 项单元测试** · **50 项端到端用例** · **23 项 Rust 壳单测**
|
||
* e2e 一项容许带可选中缀注释(如「(34 通用 + 16 Windows 专属)」)。
|
||
*/
|
||
function readmeNumbers() {
|
||
const m =
|
||
/\*\*(\d+) 项能力\*\* · \*\*(\d+) 个 MCP 工具\*\* · \*\*(\d+) 项单元测试\*\* · \*\*(\d+) 项端到端用例\*\*(?:([^)]*))? · \*\*(\d+) 项 Rust 壳单测\*\*/.exec(
|
||
readme,
|
||
);
|
||
assert.ok(m, 'README 中未找到数字行(格式应为「**N 项能力** · **N 个 MCP 工具** · ...」)');
|
||
return {
|
||
capabilities: Number(m[1]),
|
||
mcpTools: Number(m[2]),
|
||
unitTests: Number(m[3]),
|
||
e2e: Number(m[4]),
|
||
rust: Number(m[5]),
|
||
};
|
||
}
|
||
|
||
function countFiles(dir, ext) {
|
||
return readdirSync(join(root, dir))
|
||
.filter((f) => f.endsWith(ext))
|
||
.map((f) => join(root, dir, f));
|
||
}
|
||
|
||
test('README 能力数与 manifest 实测一致(增能力不改 README 会红)', () => {
|
||
const manifestFiles = readdirSync(join(root, 'packages/protocol/src/capabilities/manifest')).filter((f) =>
|
||
f.endsWith('.ts'),
|
||
);
|
||
let caps = 0;
|
||
for (const f of manifestFiles) {
|
||
const src = readFileSync(join(root, 'packages/protocol/src/capabilities/manifest', f), 'utf8');
|
||
caps += [...src.matchAll(/name: CapabilityNames\./g)].length;
|
||
}
|
||
assert.equal(readmeNumbers().capabilities, caps, `README 能力数与 manifest 不符(实测 ${caps})`);
|
||
});
|
||
|
||
test('README MCP 工具数与 defs.ts 实测一致', () => {
|
||
const src = readFileSync(join(root, 'apps/mcp/src/tools/defs.ts'), 'utf8');
|
||
const n = [...src.matchAll(/name: 'na_/g)].length;
|
||
assert.equal(readmeNumbers().mcpTools, n, `README MCP 工具数与 defs.ts 不符(实测 ${n})`);
|
||
});
|
||
|
||
test('README MCP 工具表包含 na_audit_l2(v2.1.0 新工具必须上榜)', () => {
|
||
assert.match(readme, /na_audit_l2/, 'README 工具表缺少 na_audit_l2 —— 新增 MCP 工具必须同步文档');
|
||
});
|
||
|
||
test('README 单元测试数与 node --test 实测一致', () => {
|
||
const documented = readmeNumbers().unitTests;
|
||
if (isChild) {
|
||
// 子进程(被本守卫 spawn 的全量运行)内不再递归 spawn:
|
||
// 总数由父进程从子进程汇总行读取,此处直接通过与父进程相同的值。
|
||
assert.ok(documented > 0, 'README 单测数应为正数');
|
||
return;
|
||
}
|
||
const files = countFiles('tests/unit', '.test.mjs');
|
||
// ⚠️ 必须剥掉 NODE_TEST_CONTEXT:node --test 多文件模式下每个测试文件跑在
|
||
// child-v8 上下文中,该变量会被我们的子进程继承 —— 子 node 会以为自己
|
||
// 「已被父测试运行器管理」而拒绝执行测试(秒退、无 TAP 输出)。
|
||
const childEnv = { ...process.env, NA_DOCS_NUMBERS_CHILD: '1' };
|
||
for (const k of Object.keys(childEnv)) {
|
||
if (k.startsWith('NODE_TEST_')) delete childEnv[k];
|
||
}
|
||
const out = execFileSync(process.execPath, ['--test', ...files], {
|
||
cwd: root,
|
||
env: childEnv,
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
timeout: 120_000,
|
||
});
|
||
const matches = [...out.matchAll(/^# tests (\d+)$/gm)];
|
||
assert.ok(matches.length > 0, '未找到 node --test 汇总行');
|
||
const actual = Number(matches[matches.length - 1][1]);
|
||
assert.equal(documented, actual, `README 单测数与实测不符(实测 ${actual},含本守卫自身)`);
|
||
});
|
||
|
||
test('README 端到端用例数与 e2e 源码实测一致', () => {
|
||
let e2e = 0;
|
||
for (const f of ['tests/e2e/run.mjs', 'tests/e2e/windows.mjs']) {
|
||
const src = readFileSync(join(root, f), 'utf8');
|
||
e2e += [...src.matchAll(/await test\(/g)].length;
|
||
}
|
||
assert.equal(readmeNumbers().e2e, e2e, `README e2e 数与 run.mjs+windows.mjs 不符(实测 ${e2e})`);
|
||
});
|
||
|
||
test('README Rust 壳单测数与 src-tauri 源码实测一致', () => {
|
||
const rustDir = join(root, 'apps/console/src-tauri/src');
|
||
let n = 0;
|
||
for (const f of readdirSync(rustDir).filter((x) => x.endsWith('.rs'))) {
|
||
const src = readFileSync(join(rustDir, f), 'utf8');
|
||
n += [...src.matchAll(/#\[(tokio::)?test\]/g)].length;
|
||
}
|
||
assert.equal(readmeNumbers().rust, n, `README Rust 数与源码实测不符(实测 ${n},CI 在 ubuntu 全量编译运行)`);
|
||
});
|