Commit fb25578fd4883008c886372737b308608f8e1a3a

Authored by yibo
1 parent 74b4e364

test: 把「指路牌不许指向不存在的地方」固化成第六条守卫

手工扫了两轮,扫出的是三类会持续复发的失真,不是零星笔误:
  ① package.json 里 12 处引用指向从未在 git 历史存在过的文件(server/src/cli/auto-dev.js 一族)
  ② CLAUDE.md 的 docs/ 那行列了 5 个文件名,文档移进 done/ 之后全成死链
  ③ 数据存储整节把两个不同的 data 根当成一个写,server/conversations/ 这个路径根本不存在

共性是**不报错、不影响测试、git status 干净**,只有照着做的人(或 agent)撞墙才发现。
而 CLAUDE.md 是每个会话都会加载进上下文的文件 —— 写错等于把错误信息直接灌给每一个后来者。

四条扫描:
  - CLAUDE.md 里的仓库路径都存在
  - CLAUDE.md 里出现的每条 npm run 都在 package.json 里(守反方向:删脚本忘改文档)
  - package.json 的 scripts / bin / main 指向的文件都存在
  - docs/README.md 的相对链接都指向真实文件

判据刻意窄(本仓库有过「防线被整条注释掉」的教训):路径只认「以仓库顶层目录开头 + 含 /」,
且跳过 gitignored 的 —— 那是运行态产物(web/dist/、data/companies/、skills/),在干净机器上
本来就不存在,缺席不是文档错误;这与 no-stale-templates-path.test.js 用的是同一条判据。
上下文里的相对片段(_base/、todo/future/)、glob、<repo>/… 示意写法一律不判。

四条各带「样本非空」下限断言(当前 26 路径 / 16 script 名 / 6 入口引用 / 22 链接),
下限取当前值一半左右:正则一写错就会以「零违例」全绿,那和没有这条测试是一回事。

验证:把同一套判据跑在旧 revision 的文件内容上(只读 git,不碰工作区)——
  3e718aa:路径 ❌3 条、入口 ❌10 条
  d6214f8:路径 ❌4 条、入口 ❌10 条
  HEAD   :全绿
顺带修掉自己写的一处假路径占位符(`data/xxx` → 「以 `data/` 开头的路径」),
文档里本来就不该出现假路径。

28-followups 守卫清单补到第六条,并写明后两条与前四条的形状差异。
npm run test:unit 1061/1061 绿。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CLAUDE.md
@@ -122,7 +122,7 @@ npm run okr:migrate # goals → okr 存量迁移 @@ -122,7 +122,7 @@ npm run okr:migrate # goals → okr 存量迁移
122 122
123 > [!IMPORTANT] 123 > [!IMPORTANT]
124 > **有两个不同的 `data/` 根,别混**。ArenaSK 时代的三个库落在**仓库外面一层**, 124 > **有两个不同的 `data/` 根,别混**。ArenaSK 时代的三个库落在**仓库外面一层**,
125 -> LemonBoss 的数据在**仓库内**。看到 `data/xxx` 先确认是哪一个 —— 备份、迁移、 125 +> LemonBoss 的数据在**仓库内**。看到以 `data/` 开头的路径先确认是哪一个 —— 备份、迁移、
126 > 换机器时漏掉仓库外那半边,skill / 用户 / benchmark 数据会静默丢失。 126 > 换机器时漏掉仓库外那半边,skill / 用户 / benchmark 数据会静默丢失。
127 > 判据看源码里 `path.resolve(__dirname, ...)` 的层数(当前 11 个模块指仓库内、3 个指仓库外)。 127 > 判据看源码里 `path.resolve(__dirname, ...)` 的层数(当前 11 个模块指仓库内、3 个指仓库外)。
128 128
docs/todo/system-design/28-followups.md
@@ -95,12 +95,12 @@ @@ -95,12 +95,12 @@
95 95
96 ## 五、已经立起来的守卫(别重复造,也别删) 96 ## 五、已经立起来的守卫(别重复造,也别删)
97 97
98 -这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五条出自 CLI 进程泄漏那一轮)。  
99 -**前四条**守的都是「同一个事实只能有一处持有者」,而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以 98 +这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五、六条出自 CLI 进程泄漏那一轮)。
  99 +**前四条**守的都是「同一个事实只能有一处持有者」,而且**六条都带「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以
100 「零违例」的形式全绿,一条永远通过的不变式测试和没有这条测试是一回事。 100 「零违例」的形式全绿,一条永远通过的不变式测试和没有这条测试是一回事。
101 101
102 -第五条形状不同:它守的不是「持有者唯一」而是**资源生命周期**(每一条创建路径都要有对应的回收路径),  
103 -判据也不靠扫源码,而是把回收行为当黑盒去探。列在一起是因为「别重复造,也别删」这条规矩对它同样适用。 102 +后两条形状不同:第五条守的是**资源生命周期**(每一条创建路径都要有对应的回收路径),判据不靠扫源码,而是把回收行为当黑盒去探;
  103 +第六条守的是**指路牌的有效性**(文档 / 清单里指向的东西必须真实存在)。列在一起是因为「别重复造,也别删」这条规矩对它们同样适用。
104 104
105 | 测试 | 守什么 | 违例时 | 105 | 测试 | 守什么 | 违例时 |
106 |------|--------|--------| 106 |------|--------|--------|
@@ -109,6 +109,7 @@ @@ -109,6 +109,7 @@
109 | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 | 109 | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 |
110 | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 | 110 | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 |
111 | `agent-chat-session-reclaim.test.js` | agent-chat 的 `sessions` 池:一次性 convId 的调用跑完必须回收,可复用的(pipeline / 人类对话)必须留下并受 TTL 兜底;顺带锁 `CALL_KINDS` 的标注 | 探针 `/chat/:convId/stop` 报告会话还在 | 111 | `agent-chat-session-reclaim.test.js` | agent-chat 的 `sessions` 池:一次性 convId 的调用跑完必须回收,可复用的(pipeline / 人类对话)必须留下并受 TTL 兜底;顺带锁 `CALL_KINDS` 的标注 | 探针 `/chat/:convId/stop` 报告会话还在 |
  112 +| `no-dead-path-references.test.js` | CLAUDE.md 的仓库路径与 npm run、package.json 的 scripts/bin/main、docs/README.md 的相对链接,四类引用都必须指向真实存在的东西 | 列出死引用的出处与目标 |
112 113
113 > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`, 114 > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`,
114 > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。 115 > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。
@@ -125,6 +126,17 @@ @@ -125,6 +126,17 @@
125 > 会复用的(pipeline 被驳回重做、人类多轮对话)留着走 TTL,不复用的跑完即回收。 126 > 会复用的(pipeline 被驳回重做、人类多轮对话)留着走 TTL,不复用的跑完即回收。
126 > 不要按「是不是后台调用」来分 —— 五类后台调用里 pipeline 就是要留的那一类。 127 > 不要按「是不是后台调用」来分 —— 五类后台调用里 pipeline 就是要留的那一类。
127 128
  129 +> 第六条是手工扫两轮之后固化下来的。手工那两轮扫出:package.json 里 12 处引用指向**从未在 git 历史存在过**的文件
  130 +> (`server/src/cli/auto-dev.js` 一族,`npm run example` 跑起来就是 MODULE_NOT_FOUND);CLAUDE.md 的 `docs/` 那行
  131 +> 列的 5 个文件名在文档移进 `done/` 后全成死链;数据存储整节把两个不同的 data 根当成一个写。
  132 +> 三类共性是**不报错、不影响测试、`git status` 干净**,只有照着做的人(或 agent)撞墙才发现 ——
  133 +> 而 CLAUDE.md 是每个会话都会加载进上下文的文件,写错等于把错误信息直接灌给每一个后来者。
  134 +>
  135 +> 它的判据刻意窄:只认「以仓库顶层目录开头 + 含 `/`」的路径,且**跳过 gitignored 的**
  136 +> (那是运行态产物,在干净机器上本来就不存在,缺席不是文档错误 —— 与第二条同一条判据)。
  137 +> 上下文里的相对片段(`_base/`、`todo/future/`)、glob、`<repo>/…` 示意写法一律不判。
  138 +> 宽一档就会把这些一起判红,防线于是被人整条注释掉。
  139 +
128 **改动它们之前先想清楚**:这几条的价值全在于「红了才有用」。 140 **改动它们之前先想清楚**:这几条的价值全在于「红了才有用」。
129 放宽判据、加例外、或者把断言改成永远成立的形式,等于把守卫拆掉 —— 141 放宽判据、加例外、或者把断言改成永远成立的形式,等于把守卫拆掉 ——
130 本仓库有过「隔离是表演」的测试污染前科。 142 本仓库有过「隔离是表演」的测试污染前科。
server/tests/unit/no-dead-path-references.test.js 0 → 100644
  1 +/**
  2 + * 元测试 —— 指路的文档 / 清单里不许出现「指向不存在的东西」的引用
  3 + *
  4 + * ── 来历 ──────────────────────────────────────────────────────────────────
  5 + * 手工扫了两轮,扫出的不是零星笔误,是三类会持续复发的失真:
  6 + *
  7 + * ① package.json 里 12 处引用指向**从未在 git 历史里存在过**的文件
  8 + * (`server/src/cli/auto-dev.js`、`server/src/utils/*`、`skills/whatsapp-bot/*` …)。
  9 + * `npm run example` 跑起来是 MODULE_NOT_FOUND —— 而 CLAUDE.md 把它们当可用命令列着。
  10 + * ② CLAUDE.md 的 `docs/` 那一行列了 5 个具体文件名,文档移进 `docs/done/` 之后全成死链。
  11 + * 根因是同一份清单被抄在两处(CLAUDE.md 一份、docs/README.md 一份),一搬家就烂一份。
  12 + * ③ 数据存储那一节把两个不同的 data 根当成一个写,`server/conversations/` 这个路径根本不存在。
  13 + *
  14 + * 三类的共性:**不报错、不影响测试、git status 干净**,只有照着做的人(或 agent)撞墙才发现。
  15 + * CLAUDE.md 是每个会话都会加载进上下文的文件,它写错等于把错误信息直接灌给每一个后来者 ——
  16 + * 所以这条防线守的是「指路牌不许指向不存在的地方」。
  17 + *
  18 + * ── 判据为什么窄 ──────────────────────────────────────────────────────────
  19 + * 路径只认「以仓库顶层目录开头 + 含 `/`」的那一种(`server/routes/devloop.js`、`docs/README.md`)。
  20 + * 排除掉:
  21 + * - 上下文里的相对片段(`_base/`、`todo/future/`、`agents/` —— 它们在各自句子里有前缀,
  22 + * 单独拎出来当路径判必然全红)
  23 + * - 含 `*` 的 glob、含空格的短语、`<repo>/...` 这类示意写法
  24 + * - **gitignored 的路径**:那是运行态产物(`web/dist/`、`data/companies/`、`skills/`),
  25 + * 它在一台干净机器上本来就不存在,缺席不是文档错误。这条判据与
  26 + * `no-stale-templates-path.test.js` 用的是同一条(跟踪的才是定义,未跟踪的是运行态)。
  27 + * 宽一档就会把上面这些一起判红,防线于是被人整条注释掉 —— 那比没有更糟。
  28 + *
  29 + * ── 「样本非空」前置断言 ───────────────────────────────────────────────────
  30 + * 四条扫描各带一条下限断言。少了它,正则一写错 / 文件一改名,就会以「零违例」的形式全绿 ——
  31 + * 一条永远通过的不变式测试和没有这条测试是一回事。下限取当前值的一半左右,
  32 + * 留出正常增删的余量,但挡得住「扫了个寂寞」。
  33 + */
  34 +import { describe, it, expect } from 'vitest';
  35 +import fs from 'fs';
  36 +import path from 'path';
  37 +import { execFileSync } from 'child_process';
  38 +
  39 +const ROOT = path.resolve(__dirname, '../../..');
  40 +const read = (p) => fs.readFileSync(path.join(ROOT, p), 'utf-8');
  41 +const exists = (p) => fs.existsSync(path.join(ROOT, p));
  42 +
  43 +/** 仓库顶层目录名,用来认「这是一条仓库相对路径」而不是句子里的片段 */
  44 +const TOP_DIRS = new Set(
  45 + fs.readdirSync(ROOT, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name),
  46 +);
  47 +
  48 +/**
  49 + * 一次 git check-ignore 批量问完(逐条 spawn 会慢一个数量级)。
  50 + * 没有任何一条被忽略时 git 以 exit 1 结束,那不是错误,要吞掉。
  51 + */
  52 +function ignoredSet(paths) {
  53 + if (paths.length === 0) return new Set();
  54 + try {
  55 + const out = execFileSync('git', ['check-ignore', '--stdin'], {
  56 + cwd: ROOT, input: paths.join('\n'), encoding: 'utf-8',
  57 + });
  58 + return new Set(out.split('\n').filter(Boolean));
  59 + } catch (err) {
  60 + if (err.status === 1) return new Set(String(err.stdout || '').split('\n').filter(Boolean));
  61 + throw err;
  62 + }
  63 +}
  64 +
  65 +/** 反引号里那些长得像仓库相对路径的串 */
  66 +function repoPathsIn(md) {
  67 + const out = new Set();
  68 + for (const m of md.matchAll(/`([^`\n]+)`/g)) {
  69 + const t = m[1].replace(/\/$/, '');
  70 + if (!t.includes('/') || t.includes('*') || t.includes(' ') || t.startsWith('<')) continue;
  71 + if (!TOP_DIRS.has(t.split('/')[0])) continue;
  72 + out.add(t);
  73 + }
  74 + return [...out];
  75 +}
  76 +
  77 +const pkg = JSON.parse(read('package.json'));
  78 +
  79 +describe('指路牌不许指向不存在的地方', () => {
  80 + it('CLAUDE.md 里的仓库路径都真实存在', () => {
  81 + const cands = repoPathsIn(read('CLAUDE.md'));
  82 + const ignored = ignoredSet(cands);
  83 + const checked = cands.filter((p) => !ignored.has(p));
  84 +
  85 + expect(checked.length).toBeGreaterThan(12); // 样本非空:当前 26 条
  86 + expect(checked.filter((p) => !exists(p))).toEqual([]);
  87 + });
  88 +
  89 + it('CLAUDE.md 里出现的每条 npm run 都在 package.json 里', () => {
  90 + // 兼容 `npm run web / web:build` 这种一行写两个的形式
  91 + const names = new Set();
  92 + for (const m of read('CLAUDE.md').matchAll(/npm run ([\w:-]+(?:\s*\/\s*[\w:-]+)*)/g)) {
  93 + m[1].split('/').forEach((n) => names.add(n.trim()));
  94 + }
  95 + expect(names.size).toBeGreaterThan(6); // 样本非空:当前 16 个
  96 + expect([...names].filter((n) => !pkg.scripts?.[n])).toEqual([]);
  97 + });
  98 +
  99 + it('package.json 的 scripts / bin / main 指向的文件都存在', () => {
  100 + const refs = [];
  101 + for (const [k, v] of Object.entries(pkg.scripts || {})) {
  102 + for (const m of String(v).matchAll(/(?:^|&&|\|\||;)\s*(?:node|bash|sh)\s+(\S+\.(?:js|sh))/g)) {
  103 + refs.push([`scripts.${k}`, m[1]]);
  104 + }
  105 + }
  106 + for (const [k, v] of Object.entries(pkg.bin || {})) refs.push([`bin.${k}`, String(v).replace(/^\.\//, '')]);
  107 + if (pkg.main) refs.push(['main', pkg.main]);
  108 +
  109 + expect(refs.length).toBeGreaterThan(3); // 样本非空:当前 6 条
  110 + const ignored = ignoredSet(refs.map(([, p]) => p));
  111 + const bad = refs.filter(([, p]) => !ignored.has(p) && !exists(p)).map(([k, p]) => `${k} → ${p}`);
  112 + expect(bad).toEqual([]);
  113 + });
  114 +
  115 + it('docs/README.md 的相对链接都指向真实文件', () => {
  116 + const links = [...read('docs/README.md').matchAll(/\]\((\.\/[^)#]+)\)/g)].map((m) => 'docs/' + m[1].slice(2));
  117 + expect(links.length).toBeGreaterThan(10); // 样本非空:当前 22 条
  118 + expect(links.filter((p) => !exists(p))).toEqual([]);
  119 + });
  120 +});