Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 30 additions & 0 deletions .changeset/doc-authoring-roots-docs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
---
---

chore(tooling): 把 `docs/` 的活语料纳入 doc-authoring 守卫,历史快照按路径豁免 (#4929)

`scripts/check-doc-authoring.mjs` 的 `ROOTS` 一直是 `['.claude', 'skills', 'content']`。
#4916 修的是「声明了但解析不到的 root」;这条是它的**对称方向** —— 一个真实存在、
真的在教 metadata 编写、却从来没被声明过的目录。`docs/` 就是那个目录:
`docs/notes/crm-development-standards.mdx` 单独一份就有 16 个 ts 围栏块,
ADR-0010 / 0015 / 0017 / 0057 和 `docs/design/permission-model.md` 里都有 `defineX(...)`。
AGENTS.md Prime Directive #13 要求每个 agent 在改动 ADR 治下的行为前先 grep ADR,
所以一份 ADR 里的裸字面量会被下一个 agent 原样抄进 app 代码,和 `skills/` 里的坏样本没有区别。

`ROOTS` 现为 `['.claude', 'docs', 'skills', 'content']`。取 `docs` 而不是三个子目录,
理由与 #4913 取 `.claude` 而非 `.claude/skills` 相同:以后新增的子目录**一进来就在范围内**,
不会以同样的方式被漏第二次;手写的顶层指南(`docs/protocol-upgrade-guide.md`、
`docs/upgrading-to-11.md` 等)也因此在内。扫描文件数 219 → 360。

`docs/audits`、`docs/handoff`、`docs/plans` 用 #4915 的 `SKIP_PATHS` 机制按路径排除。
它们是有日期的一次性过程记录 —— 某一天写下的、关于仓库当天状态的审计/交接/计划,
没有任何一段是以「照这样写」提供给读者的。把它们纳入等于让两个月前的 handoff 永久
受今天的 lint 约束,而那种红只有两条出路:改记录(等于伪造史料),或者晚一场争论之后
照样加豁免。脚本注释写明了这是**永久豁免、不是待办**,判断线是「这份文档现在是否在教
你怎么写 metadata」,不是「它是否在 docs/ 下」。

纳入时全仓零违规,现在纳入是零成本 —— 这正是纳入的最佳时机。双向证明折进了常驻
`--self-test`:`docs/adr` 里的裸字面量必须判红,**同样内容**放进三个豁免目录必须保持绿。
两个方向各自单独成立时都会被一个方向错误的范围满足,所以两半一起断言。

纯工具链改动,不发布任何包。
97 changes: 88 additions & 9 deletions scripts/check-doc-authoring.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,12 +27,37 @@
// next subdirectory added under it is covered on arrival rather than missed the
// same way twice.
//
// ## `docs/` is corpus too — but not all of it (#4929)
//
// #4916 fixed one direction (a root declared but no longer resolvable). This is
// the symmetric one: a directory that really exists, really teaches metadata
// authoring, and was simply never declared. `docs/` was that directory —
// `docs/notes/crm-development-standards.mdx` alone carries 16 ts blocks, and
// ADR-0010 / 0015 / 0017 / 0057 plus `docs/design/permission-model.md` all show
// `defineX(...)` calls. AGENTS.md Prime Directive #13 sends every agent to grep
// the ADRs before changing behaviour under them, so a bare literal in an ADR is
// copied into app code exactly the way one in `skills/` is. It was added while
// the corpus was still at zero violations: that window is the cheapest possible
// moment to take a directory in, and once it closes the same decision becomes an
// argument about either rewriting history or granting an exemption.
//
// Three subtrees are exempt **by path** below. They are process records, not
// corpus — read the SKIP_PATHS comment for why that is a permanent exemption and
// not a backlog item.
//
// The root is `docs`, not the three live subdirectories, for the same reason
// #4913 took `.claude` rather than `.claude/skills`: a new subdirectory under it
// is covered on arrival instead of being missed the same way twice. That also
// covers the hand-written top-level guides (`docs/protocol-upgrade-guide.md`,
// `docs/upgrading-to-11.md`, …), which are live instructions to the reader and
// belong in scope.
//
// ## Dead roots are a hard error (#4916)
//
// `collectFiles()` used to walk each root inside `try { ... } catch {}`. Rename,
// move or delete any one of them and the ENOENT was swallowed in place: the scan
// finished the *remaining* roots and printed `✓ ... N files clean`, exit 0. From
// outside, "all three roots are clean" and "one root was never opened" are the
// outside, "every root is clean" and "one root was never opened" are the
// same output with a smaller N, and nobody reads N. So every ROOT is now resolved
// at startup and an unresolvable one fails the gate **by name**. There is no
// optional root and no empty catch — see `assertRootsResolvable` for why a
Expand All @@ -43,7 +68,7 @@ import {
import { tmpdir } from 'node:os';
import { dirname, join, sep } from 'node:path';

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

Expand Down Expand Up @@ -100,9 +147,9 @@ class DeadRootError extends Error {
*
* Deliberately no whitelist / no "optional root" flag. A whitelist is the right
* shape when a root is *legitimately* absent in some checkout form, and none of
* these three are: `.claude`, `skills` and `content` are all git-tracked
* these are: `.claude`, `docs`, `skills` and `content` are all git-tracked
* directories with tracked files in them, so any checkout that can run
* `pnpm check:doc-authoring` at the repo root has all three. Adding an optional
* `pnpm check:doc-authoring` at the repo root has all of them. Adding an optional
* marker "just in case" would hand the next author a supported way to silence this
* failure (`optional: true`) instead of fixing the rename — which is the empty
* `catch {}` again, only spelled politely. If a root ever does become legitimately
Expand Down Expand Up @@ -175,6 +222,19 @@ function selfTest() {
// ...and one in another agent's worktree copy must NOT be, or every parallel
// agent's in-flight branch becomes this gate's problem.
'.claude/worktrees/other-agent/skills/demo/SKILL.md': bare,
// #4929, both directions. The live `docs/` corpus is in scope...
'docs/adr/0010-metadata-protection-model.md': bare,
'docs/notes/crm-development-standards.mdx': bareNs,
'docs/design/permission-model.md': wrapped,
// ...including hand-written top-level guides, since the root is `docs`.
'docs/protocol-upgrade-guide.md': bare,
// ...and the dated process records are exempt by path. The SAME violating
// body sits in each of these three: if the exemption ever stops matching,
// these turn red and say so, instead of the pair of assertions below both
// passing for the wrong reason.
'docs/audits/2026-06-spec-audit.md': bare,
'docs/handoff/2026-06-handoff.md': bare,
'docs/plans/v18-rollout.md': bare,
// Pre-existing roots keep working.
'skills/legit/SKILL.md': wrapped,
'content/docs/ui/pages.mdx': [jsFence, prose].join('\n\n'),
Expand Down Expand Up @@ -208,12 +268,31 @@ function selfTest() {
false,
);
expect('SKIP_FILES still applies', files.includes('content/docs/ai/skills-reference.mdx'), false);
expect('markdown files collected', files.length, 4);
expect('markdown files collected', files.length, 8);
expect('bare literal in .claude/skills is a violation', violations.some((v) => v.file === '.claude/skills/demo/SKILL.md'), true);
expect('namespaced Input alias in .claude/agents is a violation', violations.some((v) => v.file === '.claude/agents/os-dev.md'), true);
expect('defineX factory form passes', violations.some((v) => v.file === 'skills/legit/SKILL.md'), false);
expect('non-ts fence and prose pass', violations.some((v) => v.file === 'content/docs/ui/pages.mdx'), false);
expect('total violations', violations.length, 2);

// --- #4929: the live docs/ corpus is reachable, the process records are not. ---
// Stated as two halves of one claim, because either half alone is satisfied by
// a scope that is simply wrong in the other direction: "docs/adr is red" is
// also true of a scope that swallows the whole of docs/, and "docs/handoff is
// green" is also true of the pre-#4929 scope that never opened docs/ at all.
expect('docs/adr is walked', files.includes('docs/adr/0010-metadata-protection-model.md'), true);
expect('docs/notes is walked', files.includes('docs/notes/crm-development-standards.mdx'), true);
expect('docs/design is walked', files.includes('docs/design/permission-model.md'), true);
expect('top-level docs guides are walked', files.includes('docs/protocol-upgrade-guide.md'), true);
expect('bare literal in docs/adr is a violation', violations.some((v) => v.file === 'docs/adr/0010-metadata-protection-model.md'), true);
expect('namespaced Input alias in docs/notes is a violation', violations.some((v) => v.file === 'docs/notes/crm-development-standards.mdx'), true);
expect('bare literal in a top-level docs guide is a violation', violations.some((v) => v.file === 'docs/protocol-upgrade-guide.md'), true);
expect('defineX form in docs/design passes', violations.some((v) => v.file === 'docs/design/permission-model.md'), false);
for (const exempt of ['docs/audits', 'docs/handoff', 'docs/plans']) {
expect(`${exempt} is not walked`, files.some((f) => f.startsWith(`${exempt}/`)), false);
expect(`${exempt} reports no violation`, violations.some((v) => v.file.startsWith(`${exempt}/`)), false);
}

expect('total violations', violations.length, 5);

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

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

// A ROOT that exists but is not a directory is dead in the same way: the old
// `catch {}` swallowed its ENOTDIR exactly as it swallowed ENOENT.
Expand All @@ -255,7 +334,7 @@ function selfTest() {
console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`);
process.exit(1);
}
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.');
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.');
}

function main() {
Expand Down
Loading