1313// being wrapped in the `defineX(...)` factory, and fails if it finds one.
1414//
1515// node scripts/check-doc-authoring.mjs
16- import { readdirSync , readFileSync , statSync } from 'node:fs' ;
17- import { join } from 'node:path' ;
16+ // node scripts/check-doc-authoring.mjs --self-test
17+ //
18+ // ## Scope (#4913)
19+ //
20+ // `.claude/` is in scope for the same reason `skills/` is, and more so: the
21+ // published `skills/` corpus is what AI authors *apps* from, while `.claude/`
22+ // (skills, agent definitions, workflows) is the operating manual every agent
23+ // session loads and copies from. A bare literal taught there is copied into app
24+ // code by the next agent that reads it. The root was `['skills', 'content']`
25+ // until #4913 — top-level `skills/` only — so nothing checked the corpus the
26+ // agents themselves read. The root is `.claude`, not `.claude/skills`, so the
27+ // next subdirectory added under it is covered on arrival rather than missed the
28+ // same way twice.
29+ import { mkdirSync , mkdtempSync , readdirSync , readFileSync , rmSync , statSync , writeFileSync } from 'node:fs' ;
30+ import { tmpdir } from 'node:os' ;
31+ import { dirname , join , sep } from 'node:path' ;
1832
19- const ROOTS = [ 'skills' , 'content' ] ;
33+ const ROOTS = [ '.claude' , ' skills', 'content' ] ;
2034const SKIP_DIRS = new Set ( [ 'node_modules' , '.git' , 'dist' , 'references' ] ) ;
35+ // Whole subtrees skipped by path, not by directory name — a bare name would also
36+ // skip a legitimately-named directory anywhere else in the corpus.
37+ //
38+ // `.claude/worktrees/` is where an agent's per-task git worktree lands in the
39+ // environments that keep them inside the repo (it is gitignored, and AGENTS.md
40+ // Prime Directive #11 makes one per task). This walker is `readdirSync`, not
41+ // `git ls-files`, so .gitignore does not exclude it: without this entry the scan
42+ // descends into a FULL SECOND COPY of the repository per parallel agent, which
43+ // is both slow and — worse — reports violations that belong to some other
44+ // branch's working tree. A gate whose failures are not about your change is a
45+ // gate people learn to ignore.
46+ const SKIP_PATHS = new Set ( [ '.claude/worktrees' ] ) ;
2147// Generated from spec/frontmatter — not hand-authored, don't police.
2248const SKIP_FILES = new Set ( [ 'content/docs/ai/skills-reference.mdx' ] ) ;
2349
@@ -31,40 +57,130 @@ const BARE = new RegExp(`^export const \\w+:\\s*${NS}(?:${DOMAINS})(?:Input)?\\s
3157const FENCE_OPEN = / ^ ` ` ` (?: t s | t y p e s c r i p t | t s x ) \s * $ / ;
3258const FENCE_CLOSE = / ^ ` ` ` \s * $ / ;
3359
60+ const posix = ( p ) => p . split ( sep ) . join ( '/' ) ;
61+
3462function walk ( dir , out ) {
3563 for ( const e of readdirSync ( dir ) ) {
3664 if ( SKIP_DIRS . has ( e ) ) continue ;
3765 const p = join ( dir , e ) ;
66+ if ( SKIP_PATHS . has ( posix ( p ) ) ) continue ;
3867 const s = statSync ( p ) ;
3968 if ( s . isDirectory ( ) ) walk ( p , out ) ;
40- else if ( / \. m d x ? $ / . test ( e ) && ! SKIP_FILES . has ( p ) ) out . push ( p ) ;
69+ else if ( / \. m d x ? $ / . test ( e ) && ! SKIP_FILES . has ( posix ( p ) ) ) out . push ( p ) ;
4170 }
4271}
4372
44- const files = [ ] ;
45- for ( const r of ROOTS ) { try { walk ( r , files ) ; } catch { } }
73+ /** Every Markdown/MDX file in scope, relative to the current working directory. */
74+ function collectFiles ( ) {
75+ const files = [ ] ;
76+ for ( const r of ROOTS ) { try { walk ( r , files ) ; } catch { } }
77+ return files ;
78+ }
4679
47- const violations = [ ] ;
48- for ( const file of files ) {
49- const lines = readFileSync ( file , 'utf8' ) . split ( '\n' ) ;
80+ /** Bare metadata literals inside ts/tsx fenced blocks of one file's source. */
81+ function findViolations ( source , file ) {
82+ const out = [ ] ;
83+ const lines = source . split ( '\n' ) ;
5084 let inBlock = false ;
5185 for ( let i = 0 ; i < lines . length ; i ++ ) {
5286 const ln = lines [ i ] ;
5387 if ( ! inBlock ) { if ( FENCE_OPEN . test ( ln ) ) inBlock = true ; continue ; }
5488 if ( FENCE_CLOSE . test ( ln ) ) { inBlock = false ; continue ; }
55- if ( BARE . test ( ln ) ) violations . push ( { file, line : i + 1 , text : ln . trim ( ) } ) ;
89+ if ( BARE . test ( ln ) ) out . push ( { file : posix ( file ) , line : i + 1 , text : ln . trim ( ) } ) ;
5690 }
91+ return out ;
5792}
5893
59- if ( violations . length === 0 ) {
60- console . log ( `✓ doc authoring guard: ${ files . length } files clean — no bare metadata literals.` ) ;
61- process . exit ( 0 ) ;
94+ // The reverse proof, made permanent (#4913). `.claude/` currently holds zero ts
95+ // code blocks, so adding it to ROOTS leaves the gate green — which is exactly
96+ // what "added it and it still cannot see the directory" looks like from outside.
97+ // Five defects of that family closed in one week (#4690 / #4804 / #4835 / #4868
98+ // / #4890): a gate running, green, and structurally unable to reach the thing it
99+ // claims to check. So the wiring is asserted against a real temporary tree —
100+ // walked with the real walker, from the real ROOTS — rather than only the regex.
101+ function selfTest ( ) {
102+ const bare = [ '```ts' , 'export const dashboard: Page = {' , " name: 'dashboard'," , '};' , '```' ] . join ( '\n' ) ;
103+ const bareNs = [ '```tsx' , 'export const settings: UI.PageInput = {' , '};' , '```' ] . join ( '\n' ) ;
104+ const wrapped = [ '```ts' , 'export const ok = definePage({' , '});' , '```' ] . join ( '\n' ) ;
105+ const jsFence = [ '```js' , 'export const dashboard: Page = {' , '};' , '```' ] . join ( '\n' ) ;
106+ const prose = [ 'Do not write `export const dashboard: Page = {` in app code.' ] . join ( '\n' ) ;
107+
108+ const tree = {
109+ // The whole point of #4913: a violation here must be found.
110+ '.claude/skills/demo/SKILL.md' : bare ,
111+ '.claude/agents/os-dev.md' : bareNs ,
112+ // ...and one in another agent's worktree copy must NOT be, or every parallel
113+ // agent's in-flight branch becomes this gate's problem.
114+ '.claude/worktrees/other-agent/skills/demo/SKILL.md' : bare ,
115+ // Pre-existing roots keep working.
116+ 'skills/legit/SKILL.md' : wrapped ,
117+ 'content/docs/ui/pages.mdx' : [ jsFence , prose ] . join ( '\n\n' ) ,
118+ // Not Markdown, and an explicitly exempt file.
119+ '.claude/settings.json' : '{}' ,
120+ 'content/docs/ai/skills-reference.mdx' : bare ,
121+ } ;
122+
123+ const dir = mkdtempSync ( join ( tmpdir ( ) , 'doc-authoring-selftest-' ) ) ;
124+ const cwd = process . cwd ( ) ;
125+ const failures = [ ] ;
126+ const expect = ( label , got , want ) => {
127+ if ( got !== want ) failures . push ( ` ✗ self-test "${ label } ": expected ${ want } , got ${ got } ` ) ;
128+ } ;
129+
130+ try {
131+ for ( const [ rel , body ] of Object . entries ( tree ) ) {
132+ const full = join ( dir , ...rel . split ( '/' ) ) ;
133+ mkdirSync ( dirname ( full ) , { recursive : true } ) ;
134+ writeFileSync ( full , body ) ;
135+ }
136+ process . chdir ( dir ) ;
137+ const files = collectFiles ( ) . map ( posix ) ;
138+ const violations = files . flatMap ( ( f ) => findViolations ( readFileSync ( f , 'utf8' ) , f ) ) ;
139+
140+ expect ( '.claude is walked' , files . includes ( '.claude/skills/demo/SKILL.md' ) , true ) ;
141+ expect ( '.claude is not limited to skills/' , files . includes ( '.claude/agents/os-dev.md' ) , true ) ;
142+ expect (
143+ '.claude/worktrees is skipped' ,
144+ files . some ( ( f ) => f . startsWith ( '.claude/worktrees/' ) ) ,
145+ false ,
146+ ) ;
147+ expect ( 'SKIP_FILES still applies' , files . includes ( 'content/docs/ai/skills-reference.mdx' ) , false ) ;
148+ expect ( 'markdown files collected' , files . length , 4 ) ;
149+ expect ( 'bare literal in .claude/skills is a violation' , violations . some ( ( v ) => v . file === '.claude/skills/demo/SKILL.md' ) , true ) ;
150+ expect ( 'namespaced Input alias in .claude/agents is a violation' , violations . some ( ( v ) => v . file === '.claude/agents/os-dev.md' ) , true ) ;
151+ expect ( 'defineX factory form passes' , violations . some ( ( v ) => v . file === 'skills/legit/SKILL.md' ) , false ) ;
152+ expect ( 'non-ts fence and prose pass' , violations . some ( ( v ) => v . file === 'content/docs/ui/pages.mdx' ) , false ) ;
153+ expect ( 'total violations' , violations . length , 2 ) ;
154+ } finally {
155+ process . chdir ( cwd ) ;
156+ rmSync ( dir , { recursive : true , force : true } ) ;
157+ }
158+
159+ if ( failures . length ) {
160+ console . error ( `\n✗ check-doc-authoring self-test failed:\n${ failures . join ( '\n' ) } \n` ) ;
161+ process . exit ( 1 ) ;
162+ }
163+ console . log ( '✓ check-doc-authoring self-test: scope wiring (.claude in, .claude/worktrees out) and detection both hold.' ) ;
62164}
63165
64- console . error ( `\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n` ) ;
65- for ( const v of violations ) {
66- console . error ( ` ${ v . file } :${ v . line } ` ) ;
67- console . error ( ` ${ v . text } ` ) ;
166+ function main ( ) {
167+ if ( process . argv . includes ( '--self-test' ) ) return selfTest ( ) ;
168+
169+ const files = collectFiles ( ) ;
170+ const violations = files . flatMap ( ( file ) => findViolations ( readFileSync ( file , 'utf8' ) , file ) ) ;
171+
172+ if ( violations . length === 0 ) {
173+ console . log ( `✓ doc authoring guard: ${ files . length } files clean — no bare metadata literals.` ) ;
174+ return ;
175+ }
176+
177+ console . error ( `\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n` ) ;
178+ for ( const v of violations ) {
179+ console . error ( ` ${ v . file } :${ v . line } ` ) ;
180+ console . error ( ` ${ v . text } ` ) ;
181+ }
182+ console . error ( `\n${ violations . length } violation(s). Author via e.g. \`definePage({ ... })\` — a value import that fails loudly, validates at parse time, and is the one pattern AI should learn. See ADR-0059.\n` ) ;
183+ process . exit ( 1 ) ;
68184}
69- console . error ( `\n ${ violations . length } violation(s). Author via e.g. \`definePage({ ... })\` — a value import that fails loudly, validates at parse time, and is the one pattern AI should learn. See ADR-0059.\n` ) ;
70- process . exit ( 1 ) ;
185+
186+ main ( ) ;
0 commit comments