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 全量编译运行)`); });