Commit fb25578fd4883008c886372737b308608f8e1a3a
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>
Showing
3 changed files
with
137 additions
and
5 deletions
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 | +}); |