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 {
4368import { tmpdir } from 'node:os' ;
4469import { dirname , join , sep } from 'node:path' ;
4570
46- const ROOTS = [ '.claude' , 'skills' , 'content' ] ;
71+ const ROOTS = [ '.claude' , 'docs' , ' skills', 'content' ] ;
4772const 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.
61108const 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' , / s k i l l s | c o n t e n t / . test ( deadErr ?. message ?? '' ) , false ) ;
312+ expect ( 'the failure does not blame the surviving roots' , / d o c s | s k i l l s | c o n t e n t / . 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
261340function main ( ) {
0 commit comments