diff --git a/CLAUDE.md b/CLAUDE.md index 71b2a3a..95f5426 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -122,7 +122,7 @@ npm run okr:migrate # goals → okr 存量迁移 > [!IMPORTANT] > **有两个不同的 `data/` 根,别混**。ArenaSK 时代的三个库落在**仓库外面一层**, -> LemonBoss 的数据在**仓库内**。看到 `data/xxx` 先确认是哪一个 —— 备份、迁移、 +> LemonBoss 的数据在**仓库内**。看到以 `data/` 开头的路径先确认是哪一个 —— 备份、迁移、 > 换机器时漏掉仓库外那半边,skill / 用户 / benchmark 数据会静默丢失。 > 判据看源码里 `path.resolve(__dirname, ...)` 的层数(当前 11 个模块指仓库内、3 个指仓库外)。 diff --git a/docs/todo/system-design/28-followups.md b/docs/todo/system-design/28-followups.md index bf88761..9a77ceb 100644 --- a/docs/todo/system-design/28-followups.md +++ b/docs/todo/system-design/28-followups.md @@ -95,12 +95,12 @@ ## 五、已经立起来的守卫(别重复造,也别删) -这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五条出自 CLI 进程泄漏那一轮)。 -**前四条**守的都是「同一个事实只能有一处持有者」,而且都带**「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以 +这几条是沉淀下来的不变式测试(前三条出自 agent 目录重构那一轮,第四条出自 27-P3C 批 1,第五、六条出自 CLI 进程泄漏那一轮)。 +**前四条**守的都是「同一个事实只能有一处持有者」,而且**六条都带「样本非空」前置断言** —— 少了那一条,扫描路径一写错就会以 「零违例」的形式全绿,一条永远通过的不变式测试和没有这条测试是一回事。 -第五条形状不同:它守的不是「持有者唯一」而是**资源生命周期**(每一条创建路径都要有对应的回收路径), -判据也不靠扫源码,而是把回收行为当黑盒去探。列在一起是因为「别重复造,也别删」这条规矩对它同样适用。 +后两条形状不同:第五条守的是**资源生命周期**(每一条创建路径都要有对应的回收路径),判据不靠扫源码,而是把回收行为当黑盒去探; +第六条守的是**指路牌的有效性**(文档 / 清单里指向的东西必须真实存在)。列在一起是因为「别重复造,也别删」这条规矩对它们同样适用。 | 测试 | 守什么 | 违例时 | |------|--------|--------| @@ -109,6 +109,7 @@ | `repo-hygiene.test.js` | tracked 与 ignored 不重叠 | 列出越界文件 | | `runs-root-single-holder.test.js` | 凡是把 `process.env.GRAPH_RUNS_DIR` 绑到标识符上的模块,那个标识符必须在**代码里**(不是注释里)真正被用到 | 精确点名到文件行号 | | `agent-chat-session-reclaim.test.js` | agent-chat 的 `sessions` 池:一次性 convId 的调用跑完必须回收,可复用的(pipeline / 人类对话)必须留下并受 TTL 兜底;顺带锁 `CALL_KINDS` 的标注 | 探针 `/chat/:convId/stop` 报告会话还在 | +| `no-dead-path-references.test.js` | CLAUDE.md 的仓库路径与 npm run、package.json 的 scripts/bin/main、docs/README.md 的相对链接,四类引用都必须指向真实存在的东西 | 列出死引用的出处与目标 | > 第四条的来历值得记:`goal-contract.js` 里曾有一行 `const RUNS_ROOT = process.env.GRAPH_RUNS_DIR || null`, > 注释白纸黑字写着「与 graph-engine.RUNS_ROOT 是同一个变量(测试隔离在用)」,**全文件却零引用**。 @@ -125,6 +126,17 @@ > 会复用的(pipeline 被驳回重做、人类多轮对话)留着走 TTL,不复用的跑完即回收。 > 不要按「是不是后台调用」来分 —— 五类后台调用里 pipeline 就是要留的那一类。 +> 第六条是手工扫两轮之后固化下来的。手工那两轮扫出:package.json 里 12 处引用指向**从未在 git 历史存在过**的文件 +> (`server/src/cli/auto-dev.js` 一族,`npm run example` 跑起来就是 MODULE_NOT_FOUND);CLAUDE.md 的 `docs/` 那行 +> 列的 5 个文件名在文档移进 `done/` 后全成死链;数据存储整节把两个不同的 data 根当成一个写。 +> 三类共性是**不报错、不影响测试、`git status` 干净**,只有照着做的人(或 agent)撞墙才发现 —— +> 而 CLAUDE.md 是每个会话都会加载进上下文的文件,写错等于把错误信息直接灌给每一个后来者。 +> +> 它的判据刻意窄:只认「以仓库顶层目录开头 + 含 `/`」的路径,且**跳过 gitignored 的** +> (那是运行态产物,在干净机器上本来就不存在,缺席不是文档错误 —— 与第二条同一条判据)。 +> 上下文里的相对片段(`_base/`、`todo/future/`)、glob、`/…` 示意写法一律不判。 +> 宽一档就会把这些一起判红,防线于是被人整条注释掉。 + **改动它们之前先想清楚**:这几条的价值全在于「红了才有用」。 放宽判据、加例外、或者把断言改成永远成立的形式,等于把守卫拆掉 —— 本仓库有过「隔离是表演」的测试污染前科。 diff --git a/server/tests/unit/no-dead-path-references.test.js b/server/tests/unit/no-dead-path-references.test.js new file mode 100644 index 0000000..a9e855c --- /dev/null +++ b/server/tests/unit/no-dead-path-references.test.js @@ -0,0 +1,120 @@ +/** + * 元测试 —— 指路的文档 / 清单里不许出现「指向不存在的东西」的引用 + * + * ── 来历 ────────────────────────────────────────────────────────────────── + * 手工扫了两轮,扫出的不是零星笔误,是三类会持续复发的失真: + * + * ① 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([]); + }); +});