Skip to content

Commit fe83042

Browse files
chore(tooling): doc-authoring 守卫纳入 docs/ 活语料,历史快照按路径豁免 (#4929) (#4978)
#4916 修的是「声明了却解析不到的 root」。这是它的对称方向:一个真实存在、 真的在教 metadata 编写、却从来没进过 ROOTS 的目录。docs/ 就是那个目录 —— docs/notes/crm-development-standards.mdx 有 16 个 ts 围栏块,ADR-0010/0015/ 0017/0057 和 docs/design/permission-model.md 里都有 defineX(...)。Prime Directive #13 让每个 agent 改 ADR 治下的行为前先 grep ADR,所以 ADR 里的裸 字面量会被下一个 agent 原样抄进 app 代码。 ROOTS 取 docs 而非三个子目录,理由同 #4913 取 .claude 而非 .claude/skills: 新增子目录一进来就在范围内。扫描文件数 219 → 360。 docs/audits、docs/handoff、docs/plans 用 #4915 的 SKIP_PATHS 按路径排除 —— 有日期的一次性过程记录,不是语料;纳入等于让史料永久受当前 lint 约束,而那种 红只有改记录(伪造)或事后照样加豁免两条出路。注释写明这是永久豁免而非待办。 纳入时全仓零违规,零成本。双向证明折进常驻 --self-test:docs/adr 的裸字面量 判红,同样内容放进三个豁免目录保持绿 —— 两半一起断言,因为任一半单独成立都会 被一个方向错误的范围满足。 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 88264cc commit fe83042

2 files changed

Lines changed: 118 additions & 9 deletions

File tree

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
---
2+
---
3+
4+
chore(tooling): 把 `docs/` 的活语料纳入 doc-authoring 守卫,历史快照按路径豁免 (#4929)
5+
6+
`scripts/check-doc-authoring.mjs``ROOTS` 一直是 `['.claude', 'skills', 'content']`
7+
#4916 修的是「声明了但解析不到的 root」;这条是它的**对称方向** —— 一个真实存在、
8+
真的在教 metadata 编写、却从来没被声明过的目录。`docs/` 就是那个目录:
9+
`docs/notes/crm-development-standards.mdx` 单独一份就有 16 个 ts 围栏块,
10+
ADR-0010 / 0015 / 0017 / 0057 和 `docs/design/permission-model.md` 里都有 `defineX(...)`
11+
AGENTS.md Prime Directive #13 要求每个 agent 在改动 ADR 治下的行为前先 grep ADR,
12+
所以一份 ADR 里的裸字面量会被下一个 agent 原样抄进 app 代码,和 `skills/` 里的坏样本没有区别。
13+
14+
`ROOTS` 现为 `['.claude', 'docs', 'skills', 'content']`。取 `docs` 而不是三个子目录,
15+
理由与 #4913`.claude` 而非 `.claude/skills` 相同:以后新增的子目录**一进来就在范围内**,
16+
不会以同样的方式被漏第二次;手写的顶层指南(`docs/protocol-upgrade-guide.md`
17+
`docs/upgrading-to-11.md` 等)也因此在内。扫描文件数 219 → 360。
18+
19+
`docs/audits``docs/handoff``docs/plans`#4915`SKIP_PATHS` 机制按路径排除。
20+
它们是有日期的一次性过程记录 —— 某一天写下的、关于仓库当天状态的审计/交接/计划,
21+
没有任何一段是以「照这样写」提供给读者的。把它们纳入等于让两个月前的 handoff 永久
22+
受今天的 lint 约束,而那种红只有两条出路:改记录(等于伪造史料),或者晚一场争论之后
23+
照样加豁免。脚本注释写明了这是**永久豁免、不是待办**,判断线是「这份文档现在是否在教
24+
你怎么写 metadata」,不是「它是否在 docs/ 下」。
25+
26+
纳入时全仓零违规,现在纳入是零成本 —— 这正是纳入的最佳时机。双向证明折进了常驻
27+
`--self-test`:`docs/adr` 里的裸字面量必须判红,**同样内容**放进三个豁免目录必须保持绿。
28+
两个方向各自单独成立时都会被一个方向错误的范围满足,所以两半一起断言。
29+
30+
纯工具链改动,不发布任何包。

scripts/check-doc-authoring.mjs

Lines changed: 88 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,37 @@
2727
// next subdirectory added under it is covered on arrival rather than missed the
2828
// same way twice.
2929
//
30+
// ## `docs/` is corpus too — but not all of it (#4929)
31+
//
32+
// #4916 fixed one direction (a root declared but no longer resolvable). This is
33+
// the symmetric one: a directory that really exists, really teaches metadata
34+
// authoring, and was simply never declared. `docs/` was that directory —
35+
// `docs/notes/crm-development-standards.mdx` alone carries 16 ts blocks, and
36+
// ADR-0010 / 0015 / 0017 / 0057 plus `docs/design/permission-model.md` all show
37+
// `defineX(...)` calls. AGENTS.md Prime Directive #13 sends every agent to grep
38+
// the ADRs before changing behaviour under them, so a bare literal in an ADR is
39+
// copied into app code exactly the way one in `skills/` is. It was added while
40+
// the corpus was still at zero violations: that window is the cheapest possible
41+
// moment to take a directory in, and once it closes the same decision becomes an
42+
// argument about either rewriting history or granting an exemption.
43+
//
44+
// Three subtrees are exempt **by path** below. They are process records, not
45+
// corpus — read the SKIP_PATHS comment for why that is a permanent exemption and
46+
// not a backlog item.
47+
//
48+
// The root is `docs`, not the three live subdirectories, for the same reason
49+
// #4913 took `.claude` rather than `.claude/skills`: a new subdirectory under it
50+
// is covered on arrival instead of being missed the same way twice. That also
51+
// covers the hand-written top-level guides (`docs/protocol-upgrade-guide.md`,
52+
// `docs/upgrading-to-11.md`, …), which are live instructions to the reader and
53+
// belong in scope.
54+
//
3055
// ## Dead roots are a hard error (#4916)
3156
//
3257
// `collectFiles()` used to walk each root inside `try { ... } catch {}`. Rename,
3358
// move or delete any one of them and the ENOENT was swallowed in place: the scan
3459
// finished the *remaining* roots and printed `✓ ... N files clean`, exit 0. From
35-
// outside, "all three roots are clean" and "one root was never opened" are the
60+
// outside, "every root is clean" and "one root was never opened" are the
3661
// same output with a smaller N, and nobody reads N. So every ROOT is now resolved
3762
// at startup and an unresolvable one fails the gate **by name**. There is no
3863
// optional root and no empty catch — see `assertRootsResolvable` for why a
@@ -43,7 +68,7 @@ import {
4368
import { tmpdir } from 'node:os';
4469
import { dirname, join, sep } from 'node:path';
4570

46-
const ROOTS = ['.claude', 'skills', 'content'];
71+
const ROOTS = ['.claude', 'docs', 'skills', 'content'];
4772
const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']);
4873
// Whole subtrees skipped by path, not by directory name — a bare name would also
4974
// skip a legitimately-named directory anywhere else in the corpus.
@@ -56,7 +81,29 @@ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']);
5681
// is both slow and — worse — reports violations that belong to some other
5782
// branch's working tree. A gate whose failures are not about your change is a
5883
// gate people learn to ignore.
59-
const SKIP_PATHS = new Set(['.claude/worktrees']);
84+
//
85+
// `docs/audits`, `docs/handoff` and `docs/plans` are the historical exemption
86+
// (#4929) — NOT an oversight, and NOT a backlog item to shrink later. They are
87+
// dated, one-shot process records: an audit report, a handoff note, a plan
88+
// written on a particular day about the state of the repo on that day. Nothing
89+
// in them is offered to the reader as "author it this way"; they are evidence of
90+
// what was true then. Putting them in scope would subject a two-month-old
91+
// handoff to today's lint permanently, and the only two ways out of that red are
92+
// to edit the record — which falsifies it — or to add the exemption anyway, one
93+
// argument later. The rest of `docs/` is live instruction and stays in scope, so
94+
// the line is "does this document tell you how to write metadata now?", not
95+
// "is it under docs/?". A doc that starts as a plan and becomes the standing
96+
// guide should be moved out of `docs/plans` rather than exempted in place.
97+
//
98+
// (If one of these directories is ever renamed the skip silently stops matching
99+
// and its files enter the scan — a loud red, not a silent hole, which is the
100+
// safe direction for a stale entry to fail in.)
101+
const SKIP_PATHS = new Set([
102+
'.claude/worktrees',
103+
'docs/audits',
104+
'docs/handoff',
105+
'docs/plans',
106+
]);
60107
// Generated from spec/frontmatter — not hand-authored, don't police.
61108
const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']);
62109

@@ -100,9 +147,9 @@ class DeadRootError extends Error {
100147
*
101148
* Deliberately no whitelist / no "optional root" flag. A whitelist is the right
102149
* shape when a root is *legitimately* absent in some checkout form, and none of
103-
* these three are: `.claude`, `skills` and `content` are all git-tracked
150+
* these are: `.claude`, `docs`, `skills` and `content` are all git-tracked
104151
* directories with tracked files in them, so any checkout that can run
105-
* `pnpm check:doc-authoring` at the repo root has all three. Adding an optional
152+
* `pnpm check:doc-authoring` at the repo root has all of them. Adding an optional
106153
* marker "just in case" would hand the next author a supported way to silence this
107154
* failure (`optional: true`) instead of fixing the rename — which is the empty
108155
* `catch {}` again, only spelled politely. If a root ever does become legitimately
@@ -175,6 +222,19 @@ function selfTest() {
175222
// ...and one in another agent's worktree copy must NOT be, or every parallel
176223
// agent's in-flight branch becomes this gate's problem.
177224
'.claude/worktrees/other-agent/skills/demo/SKILL.md': bare,
225+
// #4929, both directions. The live `docs/` corpus is in scope...
226+
'docs/adr/0010-metadata-protection-model.md': bare,
227+
'docs/notes/crm-development-standards.mdx': bareNs,
228+
'docs/design/permission-model.md': wrapped,
229+
// ...including hand-written top-level guides, since the root is `docs`.
230+
'docs/protocol-upgrade-guide.md': bare,
231+
// ...and the dated process records are exempt by path. The SAME violating
232+
// body sits in each of these three: if the exemption ever stops matching,
233+
// these turn red and say so, instead of the pair of assertions below both
234+
// passing for the wrong reason.
235+
'docs/audits/2026-06-spec-audit.md': bare,
236+
'docs/handoff/2026-06-handoff.md': bare,
237+
'docs/plans/v18-rollout.md': bare,
178238
// Pre-existing roots keep working.
179239
'skills/legit/SKILL.md': wrapped,
180240
'content/docs/ui/pages.mdx': [jsFence, prose].join('\n\n'),
@@ -208,12 +268,31 @@ function selfTest() {
208268
false,
209269
);
210270
expect('SKIP_FILES still applies', files.includes('content/docs/ai/skills-reference.mdx'), false);
211-
expect('markdown files collected', files.length, 4);
271+
expect('markdown files collected', files.length, 8);
212272
expect('bare literal in .claude/skills is a violation', violations.some((v) => v.file === '.claude/skills/demo/SKILL.md'), true);
213273
expect('namespaced Input alias in .claude/agents is a violation', violations.some((v) => v.file === '.claude/agents/os-dev.md'), true);
214274
expect('defineX factory form passes', violations.some((v) => v.file === 'skills/legit/SKILL.md'), false);
215275
expect('non-ts fence and prose pass', violations.some((v) => v.file === 'content/docs/ui/pages.mdx'), false);
216-
expect('total violations', violations.length, 2);
276+
277+
// --- #4929: the live docs/ corpus is reachable, the process records are not. ---
278+
// Stated as two halves of one claim, because either half alone is satisfied by
279+
// a scope that is simply wrong in the other direction: "docs/adr is red" is
280+
// also true of a scope that swallows the whole of docs/, and "docs/handoff is
281+
// green" is also true of the pre-#4929 scope that never opened docs/ at all.
282+
expect('docs/adr is walked', files.includes('docs/adr/0010-metadata-protection-model.md'), true);
283+
expect('docs/notes is walked', files.includes('docs/notes/crm-development-standards.mdx'), true);
284+
expect('docs/design is walked', files.includes('docs/design/permission-model.md'), true);
285+
expect('top-level docs guides are walked', files.includes('docs/protocol-upgrade-guide.md'), true);
286+
expect('bare literal in docs/adr is a violation', violations.some((v) => v.file === 'docs/adr/0010-metadata-protection-model.md'), true);
287+
expect('namespaced Input alias in docs/notes is a violation', violations.some((v) => v.file === 'docs/notes/crm-development-standards.mdx'), true);
288+
expect('bare literal in a top-level docs guide is a violation', violations.some((v) => v.file === 'docs/protocol-upgrade-guide.md'), true);
289+
expect('defineX form in docs/design passes', violations.some((v) => v.file === 'docs/design/permission-model.md'), false);
290+
for (const exempt of ['docs/audits', 'docs/handoff', 'docs/plans']) {
291+
expect(`${exempt} is not walked`, files.some((f) => f.startsWith(`${exempt}/`)), false);
292+
expect(`${exempt} reports no violation`, violations.some((v) => v.file.startsWith(`${exempt}/`)), false);
293+
}
294+
295+
expect('total violations', violations.length, 5);
217296

218297
// --- Reverse proof for the dead-root hard error (#4916), made permanent. ---
219298
// Everything above ran green over a tree where all three roots resolve. That
@@ -230,7 +309,7 @@ function selfTest() {
230309

231310
expect('a renamed ROOT throws instead of quietly scanning less', deadErr instanceof DeadRootError, true);
232311
expect('the failure names the dead root', deadErr?.roots?.join(',') ?? '<none>', '.claude');
233-
expect('the failure does not blame the surviving roots', /skills|content/.test(deadErr?.message ?? ''), false);
312+
expect('the failure does not blame the surviving roots', /docs|skills|content/.test(deadErr?.message ?? ''), false);
234313

235314
// A ROOT that exists but is not a directory is dead in the same way: the old
236315
// `catch {}` swallowed its ENOTDIR exactly as it swallowed ENOENT.
@@ -255,7 +334,7 @@ function selfTest() {
255334
console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`);
256335
process.exit(1);
257336
}
258-
console.log('✓ check-doc-authoring self-test: scope wiring (.claude in, .claude/worktrees out), detection, and the dead-root hard error (red when a ROOT is renamed, green when restored) all hold.');
337+
console.log('✓ check-doc-authoring self-test: scope wiring (.claude and the live docs/ corpus in, .claude/worktrees and docs/{audits,handoff,plans} out), detection, and the dead-root hard error (red when a ROOT is renamed, green when restored) all hold.');
259338
}
260339

261340
function main() {

0 commit comments

Comments
 (0)