6262// at startup and an unresolvable one fails the gate **by name**. There is no
6363// optional root and no empty catch — see `assertRootsResolvable` for why a
6464// whitelist would be the wrong shape here rather than merely unnecessary.
65+ //
66+ // ## A scan that finds nothing is a hard error too (#4932)
67+ //
68+ // #4916 closed one spelling of the evaporation: the ROOT no longer resolves.
69+ // This closes the other, which that assertion cannot see — the root resolves and
70+ // the corpus is no longer inside it. A subtree moves out, a `SKIP_PATHS` entry
71+ // widens, an authoring convention changes the extension: the root is still a
72+ // directory, so `assertRootsResolvable` is satisfied, the walk returns fewer
73+ // files, and the printed count drops in silence. `✓ 362 files clean` and
74+ // `✓ 0 files clean` are the same sentence to every reader and the same exit code
75+ // to CI — which is all of #4932: the count was printed and never asserted.
76+ //
77+ // The floor is PER ROOT, not on the total. A total floor is held up by whichever
78+ // root still has files while another empties (`.claude` alone keeps it positive),
79+ // and "part of the corpus was read" is precisely the verdict this gate must not
80+ // resolve in the corpus's favour. It is a floor of one file per declared root,
81+ // derived from the walk that just ran — deliberately NOT a ratchet against a
82+ // recorded high-water mark, which would have to be maintained and would turn
83+ // every legitimate deletion into an argument with a number.
84+ //
85+ // It lives in `collectFiles`, not in `main`, so the self-test drives the
86+ // invariant itself rather than a proxy for it.
6587import {
6688 mkdirSync , mkdtempSync , readdirSync , readFileSync , renameSync , rmSync , statSync , writeFileSync ,
6789} from 'node:fs' ;
@@ -173,17 +195,46 @@ function assertRootsResolvable(roots = ROOTS) {
173195 if ( dead . length ) throw new DeadRootError ( dead ) ;
174196}
175197
198+ /**
199+ * A declared ROOT that resolved to a directory and yielded no file to scan.
200+ * Carries the names, and the total the run would otherwise have reported.
201+ */
202+ class EmptyRootError extends Error {
203+ constructor ( empty , total ) {
204+ super ( `ROOT(s) contributed no Markdown/MDX file: ${ empty . join ( ', ' ) } (total scanned: ${ total } )` ) ;
205+ this . name = 'EmptyRootError' ;
206+ /** @type {string[] } the roots that yielded nothing. */
207+ this . roots = empty ;
208+ /** @type {number } files found across all roots — 0 when the whole scan evaporated. */
209+ this . total = total ;
210+ }
211+ }
212+
176213/**
177214 * Every Markdown/MDX file in scope, relative to the current working directory.
178215 *
179216 * Nothing here is wrapped in a catch: an unreadable root fails loudly above, and an
180217 * error *inside* `walk` (a vanished file, a permission fault) means the corpus was
181218 * only partly read — which must not be reported as a clean scan either.
219+ *
220+ * Each root must also actually YIELD something (#4932). A root that resolves but
221+ * holds no Markdown is the same evaporation as a root that does not resolve, minus
222+ * the ENOENT that made the first kind detectable: the walk succeeds, the count
223+ * shrinks, and nothing in the output distinguishes "clean" from "never read".
224+ *
225+ * @throws {DeadRootError } a declared ROOT is not a directory.
226+ * @throws {EmptyRootError } a declared ROOT resolved but contributed no file.
182227 */
183228function collectFiles ( ) {
184229 assertRootsResolvable ( ) ;
185230 const files = [ ] ;
186- for ( const r of ROOTS ) walk ( r , files ) ;
231+ const empty = [ ] ;
232+ for ( const r of ROOTS ) {
233+ const before = files . length ;
234+ walk ( r , files ) ;
235+ if ( files . length === before ) empty . push ( r ) ;
236+ }
237+ if ( empty . length ) throw new EmptyRootError ( empty , files . length ) ;
187238 return files ;
188239}
189240
@@ -325,6 +376,41 @@ function selfTest() {
325376 // ...and restoring both roots restores the green, so the red above was caused
326377 // by the broken root and nothing else.
327378 expect ( 'restoring the roots makes the scan green again' , collectFiles ( ) . length , files . length ) ;
379+
380+ // --- Reverse proof for the empty-scan hard error (#4932), same discipline. ---
381+ // The direction was decided before it was run: a root that resolves and yields
382+ // nothing must be RED, and the red must name that root only. This is the case
383+ // #4916's assertion cannot reach — nothing is renamed, nothing is unreadable,
384+ // the corpus is simply not there any more.
385+ const emptiedRoot = join ( dir , 'skills' , 'legit' , 'SKILL.md' ) ;
386+ rmSync ( emptiedRoot ) ;
387+ let emptyErr = null ;
388+ try { collectFiles ( ) ; } catch ( err ) { emptyErr = err ; }
389+ writeFileSync ( emptiedRoot , wrapped ) ;
390+
391+ expect ( 'a root that resolves but yields nothing is red' , emptyErr instanceof EmptyRootError , true ) ;
392+ expect ( 'the failure names the empty root' , emptyErr ?. roots ?. join ( ',' ) ?? '<none>' , 'skills' ) ;
393+ expect ( 'the failure does not blame the populated roots' , / \. c l a u d e | d o c s | c o n t e n t / . test ( emptyErr ?. roots ?. join ( ',' ) ?? '' ) , false ) ;
394+ // The other roots were still scanned, so the total proves the run was not
395+ // simply aborted: 8 files minus the one just removed.
396+ expect ( 'the failure reports what the run did find' , emptyErr ?. total ?? - 1 , files . length - 1 ) ;
397+ expect ( 'restoring the file makes the scan green again' , collectFiles ( ) . length , files . length ) ;
398+
399+ // ...and the extreme the issue named: every root resolves, the whole scan
400+ // finds nothing, and the old code printed `✓ 0 files clean` and exited 0.
401+ const bare2 = mkdtempSync ( join ( tmpdir ( ) , 'doc-authoring-selftest-empty-' ) ) ;
402+ let allEmptyErr = null ;
403+ try {
404+ for ( const r of ROOTS ) mkdirSync ( join ( bare2 , r ) , { recursive : true } ) ;
405+ process . chdir ( bare2 ) ;
406+ try { collectFiles ( ) ; } catch ( err ) { allEmptyErr = err ; }
407+ } finally {
408+ process . chdir ( dir ) ;
409+ rmSync ( bare2 , { recursive : true , force : true } ) ;
410+ }
411+ expect ( 'a scan that finds nothing at all is red, not "0 files clean"' , allEmptyErr instanceof EmptyRootError , true ) ;
412+ expect ( 'every empty root is named' , allEmptyErr ?. roots ?. join ( ',' ) ?? '<none>' , ROOTS . join ( ',' ) ) ;
413+ expect ( 'the zero total is reported' , allEmptyErr ?. total ?? - 1 , 0 ) ;
328414 } finally {
329415 process . chdir ( cwd ) ;
330416 rmSync ( dir , { recursive : true , force : true } ) ;
@@ -334,7 +420,7 @@ function selfTest() {
334420 console . error ( `\n✗ check-doc-authoring self-test failed:\n${ failures . join ( '\n' ) } \n` ) ;
335421 process . exit ( 1 ) ;
336422 }
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.' ) ;
423+ 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, the dead-root hard error (red when a ROOT is renamed, green when restored) and the empty-scan hard error (red when a root yields nothing and when the whole scan does , green when restored) all hold.' ) ;
338424}
339425
340426function main ( ) {
@@ -344,18 +430,38 @@ function main() {
344430 try {
345431 files = collectFiles ( ) ;
346432 } catch ( err ) {
347- if ( ! ( err instanceof DeadRootError ) ) throw err ;
348- console . error ( `\n✗ doc authoring guard: declared ROOT(s) do not resolve, so the scan would have been silently narrower:\n` ) ;
349- for ( const d of err . dead ) console . error ( ` ${ d . root } — ${ d . reason } ` ) ;
350- console . error (
351- `\nEvery entry in ROOTS (scripts/check-doc-authoring.mjs) must be a directory in the checkout,` +
352- `\nand this check runs from the repo root. If a corpus directory was renamed or moved, update` +
353- `\nROOTS to follow it; if it was deleted, remove the entry deliberately. Do NOT restore a` +
354- `\ntolerant skip: this used to be \`catch {}\`, and a dead root simply shrank the reported file` +
355- `\ncount while the gate kept printing green (#4916).\n` ,
356- ) ;
357- process . exit ( 1 ) ;
358- return ;
433+ if ( err instanceof DeadRootError ) {
434+ console . error ( `\n✗ doc authoring guard: declared ROOT(s) do not resolve, so the scan would have been silently narrower:\n` ) ;
435+ for ( const d of err . dead ) console . error ( ` ${ d . root } — ${ d . reason } ` ) ;
436+ console . error (
437+ `\nEvery entry in ROOTS (scripts/check-doc-authoring.mjs) must be a directory in the checkout,` +
438+ `\nand this check runs from the repo root. If a corpus directory was renamed or moved, update` +
439+ `\nROOTS to follow it; if it was deleted, remove the entry deliberately. Do NOT restore a` +
440+ `\ntolerant skip: this used to be \`catch {}\`, and a dead root simply shrank the reported file` +
441+ `\ncount while the gate kept printing green (#4916).\n` ,
442+ ) ;
443+ process . exit ( 1 ) ;
444+ return ;
445+ }
446+ if ( err instanceof EmptyRootError ) {
447+ console . error (
448+ `\n✗ doc authoring guard: declared ROOT(s) resolved but contributed no Markdown/MDX file, so` +
449+ `\nthis run would have reported a clean corpus it never read:\n` ,
450+ ) ;
451+ for ( const r of err . roots ) console . error ( ` ${ r } — 0 files` ) ;
452+ console . error (
453+ `\n${ err . total } file(s) were found in total across all of ROOTS.` +
454+ `\n\nEvery entry in ROOTS (scripts/check-doc-authoring.mjs) must yield at least one .md/.mdx` +
455+ `\nfile. The root still being a directory is not enough — that is all #4916's check can see.` +
456+ `\nIf the corpus moved to a new directory, point ROOTS at it; if a subtree was deliberately` +
457+ `\nemptied or removed, remove its ROOT entry in the same change. Do NOT lower this to a total` +
458+ `\ncount: one populated root would then cover for every evaporated one, which is the silent` +
459+ `\nnarrowing this assertion exists to stop (#4932).\n` ,
460+ ) ;
461+ process . exit ( 1 ) ;
462+ return ;
463+ }
464+ throw err ;
359465 }
360466 const violations = files . flatMap ( ( file ) => findViolations ( readFileSync ( file , 'utf8' ) , file ) ) ;
361467
0 commit comments