/** * 元测试 —— 指路的文档 / 清单里不许出现「指向不存在的东西」的引用 * * ── 来历 ────────────────────────────────────────────────────────────────── * 手工扫了两轮,扫出的不是零星笔误,是三类会持续复发的失真: * * ① package.json 里 12 处引用指向**从未在 git 历史里存在过**的文件 * (`server/src/cli/auto-dev.js`、`server/src/utils/*`、`skills/whatsapp-bot/*` …)。 * `npm run example` 跑起来是 MODULE_NOT_FOUND —— 而 CLAUDE.md 把它们当可用命令列着。 * ② CLAUDE.md 的 `docs/` 那一行列了 5 个具体文件名,文档移进 `docs/done/` 之后全成死链。 * 根因是同一份清单被抄在两处(CLAUDE.md 一份、docs/README.md 一份),一搬家就烂一份。 * ③ 数据存储那一节把两个不同的 data 根当成一个写,`server/conversations/` 这个路径根本不存在。 * * 三类的共性:**不报错、不影响测试、git status 干净**,只有照着做的人(或 agent)撞墙才发现。 * CLAUDE.md 是每个会话都会加载进上下文的文件,它写错等于把错误信息直接灌给每一个后来者 —— * 所以这条防线守的是「指路牌不许指向不存在的地方」。 * * ── 判据为什么窄 ────────────────────────────────────────────────────────── * 路径只认「以仓库顶层目录开头 + 含 `/`」的那一种(`server/routes/devloop.js`、`docs/README.md`)。 * 排除掉: * - 上下文里的相对片段(`_base/`、`todo/future/`、`agents/` —— 它们在各自句子里有前缀, * 单独拎出来当路径判必然全红) * - 含 `*` 的 glob、含空格的短语、`/...` 这类示意写法 * - **gitignored 的路径**:那是运行态产物(`web/dist/`、`data/companies/`、`skills/`), * 它在一台干净机器上本来就不存在,缺席不是文档错误。这条判据与 * `no-stale-templates-path.test.js` 用的是同一条(跟踪的才是定义,未跟踪的是运行态)。 * 宽一档就会把上面这些一起判红,防线于是被人整条注释掉 —— 那比没有更糟。 * * ── 「样本非空」前置断言 ─────────────────────────────────────────────────── * 四条扫描各带一条下限断言。少了它,正则一写错 / 文件一改名,就会以「零违例」的形式全绿 —— * 一条永远通过的不变式测试和没有这条测试是一回事。下限取当前值的一半左右, * 留出正常增删的余量,但挡得住「扫了个寂寞」。 */ import { describe, it, expect } from 'vitest'; import fs from 'fs'; import path from 'path'; import { execFileSync } from 'child_process'; const ROOT = path.resolve(__dirname, '../../..'); const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf-8'); const exists = (p) => fs.existsSync(path.join(ROOT, p)); /** 仓库顶层目录名,用来认「这是一条仓库相对路径」而不是句子里的片段 */ const TOP_DIRS = new Set( fs.readdirSync(ROOT, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name), ); /** * 一次 git check-ignore 批量问完(逐条 spawn 会慢一个数量级)。 * 没有任何一条被忽略时 git 以 exit 1 结束,那不是错误,要吞掉。 */ function ignoredSet(paths) { if (paths.length === 0) return new Set(); try { const out = execFileSync('git', ['check-ignore', '--stdin'], { cwd: ROOT, input: paths.join('\n'), encoding: 'utf-8', }); return new Set(out.split('\n').filter(Boolean)); } catch (err) { if (err.status === 1) return new Set(String(err.stdout || '').split('\n').filter(Boolean)); throw err; } } /** 反引号里那些长得像仓库相对路径的串 */ function repoPathsIn(md) { const out = new Set(); for (const m of md.matchAll(/`([^`\n]+)`/g)) { const t = m[1].replace(/\/$/, ''); if (!t.includes('/') || t.includes('*') || t.includes(' ') || t.startsWith('<')) continue; if (!TOP_DIRS.has(t.split('/')[0])) continue; out.add(t); } return [...out]; } const pkg = JSON.parse(read('package.json')); describe('指路牌不许指向不存在的地方', () => { it('CLAUDE.md 里的仓库路径都真实存在', () => { const cands = repoPathsIn(read('CLAUDE.md')); const ignored = ignoredSet(cands); const checked = cands.filter((p) => !ignored.has(p)); expect(checked.length).toBeGreaterThan(12); // 样本非空:当前 26 条 expect(checked.filter((p) => !exists(p))).toEqual([]); }); it('CLAUDE.md 里出现的每条 npm run 都在 package.json 里', () => { // 兼容 `npm run web / web:build` 这种一行写两个的形式 const names = new Set(); for (const m of read('CLAUDE.md').matchAll(/npm run ([\w:-]+(?:\s*\/\s*[\w:-]+)*)/g)) { m[1].split('/').forEach((n) => names.add(n.trim())); } expect(names.size).toBeGreaterThan(6); // 样本非空:当前 16 个 expect([...names].filter((n) => !pkg.scripts?.[n])).toEqual([]); }); it('package.json 的 scripts / bin / main 指向的文件都存在', () => { const refs = []; for (const [k, v] of Object.entries(pkg.scripts || {})) { for (const m of String(v).matchAll(/(?:^|&&|\|\||;)\s*(?:node|bash|sh)\s+(\S+\.(?:js|sh))/g)) { refs.push([`scripts.${k}`, m[1]]); } } for (const [k, v] of Object.entries(pkg.bin || {})) refs.push([`bin.${k}`, String(v).replace(/^\.\//, '')]); if (pkg.main) refs.push(['main', pkg.main]); expect(refs.length).toBeGreaterThan(3); // 样本非空:当前 6 条 const ignored = ignoredSet(refs.map(([, p]) => p)); const bad = refs.filter(([, p]) => !ignored.has(p) && !exists(p)).map(([k, p]) => `${k} → ${p}`); expect(bad).toEqual([]); }); it('docs/README.md 的相对链接都指向真实文件', () => { const links = [...read('docs/README.md').matchAll(/\]\((\.\/[^)#]+)\)/g)].map((m) => 'docs/' + m[1].slice(2)); expect(links.length).toBeGreaterThan(10); // 样本非空:当前 22 条 expect(links.filter((p) => !exists(p))).toEqual([]); }); });