Skip to content

Commit bf973ae

Browse files
fix(tooling): fail check-doc-authoring by name when a declared ROOT is dead (#4916) (#4934)
`collectFiles()` walked each root inside `try { walk(r, files); } catch {}`. Rename, move or delete any one of `.claude` / `skills` / `content` and its ENOENT was swallowed in place: the scan finished the remaining roots and printed `✓ doc authoring guard: N files clean`, exit 0. Measured on this tree with `.claude/` renamed away, the old code reports 215 files clean, exit 0, where the honest answer is 219 — "all three roots are clean" and "one root was never opened" are the same green line with a smaller N, and nobody reads N. `assertRootsResolvable()` now runs before any walking and throws a `DeadRootError` naming every root that is missing, unreadable, or not a directory; `main()` renders that as a red gate pointing at the dead root. No whitelist and no `optional` flag: all three roots are git-tracked directories with tracked files, so no checkout that can run this gate at the repo root is legitimately missing one, and an optional marker would be a supported way to silence the failure instead of following the rename — the empty catch, spelled politely. The inner try is gone too: an error during the walk also means the corpus was only partly read. The proof is bidirectional and permanent. `--self-test` (#4913) already walked a real temporary tree with the real walker; it now renames one root away mid-run and requires red naming that root and not the survivors, replaces another root with a file and requires the `not a directory` verdict, then restores both and requires green again. This closes what #4913's self-test could not: it stayed green with the repo's real `.claude/` renamed away, because it asserts over its own temp tree. Same discipline as #4690 / #4804 / #4835 / #4851 / #4868 / #4890. Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6bc93dc commit bf973ae

2 files changed

Lines changed: 126 additions & 5 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
---
2+
---
3+
4+
Tooling-only: `scripts/check-doc-authoring.mjs` now fails, by name, when one of its declared `ROOTS` cannot be resolved (#4916). Releases nothing — no package changes.
5+
6+
The walk was `for (const r of ROOTS) { try { walk(r, files); } catch {} }`. Rename, move or delete any one root and its ENOENT was swallowed in place: the scan finished the *remaining* roots and printed `✓ doc authoring guard: N files clean`, exit 0. Measured on this tree — with `.claude/` renamed away, the old code reported **215 files clean, exit 0** where the honest answer is 219. From outside, "all three roots are clean" and "one root was never opened" are the same green line with a smaller N, and nobody reads N. That is the sixth instance this week of one shape: a check that runs, is green, and structurally cannot reach part of its subject (#4690 / #4804 / #4835 / #4868 / #4890 / #4851).
7+
8+
`assertRootsResolvable()` now runs before any walking and throws a `DeadRootError` naming every root that is missing, unreadable, or not a directory; `main()` turns that into a red gate that says which root died and tells the author to follow the rename in `ROOTS` rather than restore a tolerant skip. **No whitelist and no `optional: true` flag**, deliberately: `.claude`, `skills` and `content` are all git-tracked directories with tracked files, so no checkout that can run this gate at the repo root is legitimately missing one. An optional marker added "just in case" would be a supported way to silence the failure instead of fixing the rename — the empty `catch {}`, spelled politely. Should a root ever become legitimately absent, that is a decision to record with its condition and a test, not a check to relax. The inner `try` is gone too: an error *during* the walk also means the corpus was only partly read, which must not print as a clean scan.
9+
10+
The proof is bidirectional and permanent, not a one-off in the PR description. `--self-test` (#4913) already walked a real temporary tree with the real walker; it now also renames one root away mid-run and requires red naming that root and *not* the survivors, replaces another root with a file and requires the `not a directory` verdict, then restores both and requires green again. Observing green proves nothing about a gate whose failure mode is scanning less — so the self-test observes red first, every run. Note what this closes that #4913's self-test could not: the old self-test stayed green with the repo's real `.claude/` renamed away, because it asserts over its own temp tree.

scripts/check-doc-authoring.mjs

Lines changed: 116 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,20 @@
2626
// agents themselves read. The root is `.claude`, not `.claude/skills`, so the
2727
// next subdirectory added under it is covered on arrival rather than missed the
2828
// same way twice.
29-
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs';
29+
//
30+
// ## Dead roots are a hard error (#4916)
31+
//
32+
// `collectFiles()` used to walk each root inside `try { ... } catch {}`. Rename,
33+
// move or delete any one of them and the ENOENT was swallowed in place: the scan
34+
// 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
36+
// same output with a smaller N, and nobody reads N. So every ROOT is now resolved
37+
// at startup and an unresolvable one fails the gate **by name**. There is no
38+
// optional root and no empty catch — see `assertRootsResolvable` for why a
39+
// whitelist would be the wrong shape here rather than merely unnecessary.
40+
import {
41+
mkdirSync, mkdtempSync, readdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync,
42+
} from 'node:fs';
3043
import { tmpdir } from 'node:os';
3144
import { dirname, join, sep } from 'node:path';
3245

@@ -70,10 +83,60 @@ function walk(dir, out) {
7083
}
7184
}
7285

73-
/** Every Markdown/MDX file in scope, relative to the current working directory. */
86+
/** A declared ROOT that could not be resolved to a directory. Carries the names. */
87+
class DeadRootError extends Error {
88+
constructor(dead) {
89+
super(`unresolvable ROOT(s): ${dead.map((d) => `${d.root}${d.reason}`).join('; ')}`);
90+
this.name = 'DeadRootError';
91+
this.dead = dead;
92+
/** @type {string[]} just the root names, for callers that only need to point. */
93+
this.roots = dead.map((d) => d.root);
94+
}
95+
}
96+
97+
/**
98+
* Resolve every declared ROOT before scanning anything; throw naming the ones that
99+
* are not directories.
100+
*
101+
* Deliberately no whitelist / no "optional root" flag. A whitelist is the right
102+
* 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
104+
* 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
106+
* marker "just in case" would hand the next author a supported way to silence this
107+
* failure (`optional: true`) instead of fixing the rename — which is the empty
108+
* `catch {}` again, only spelled politely. If a root ever does become legitimately
109+
* absent, that is a real decision: add the entry *with* its condition and a test,
110+
* don't relax the check.
111+
*
112+
* @throws {DeadRootError}
113+
*/
114+
function assertRootsResolvable(roots = ROOTS) {
115+
const dead = [];
116+
for (const root of roots) {
117+
let stat = null;
118+
try {
119+
stat = statSync(root);
120+
} catch (err) {
121+
dead.push({ root, reason: err?.code === 'ENOENT' ? 'does not exist' : `cannot be read (${err?.code ?? err})` });
122+
continue;
123+
}
124+
if (!stat.isDirectory()) dead.push({ root, reason: 'exists but is not a directory' });
125+
}
126+
if (dead.length) throw new DeadRootError(dead);
127+
}
128+
129+
/**
130+
* Every Markdown/MDX file in scope, relative to the current working directory.
131+
*
132+
* Nothing here is wrapped in a catch: an unreadable root fails loudly above, and an
133+
* error *inside* `walk` (a vanished file, a permission fault) means the corpus was
134+
* only partly read — which must not be reported as a clean scan either.
135+
*/
74136
function collectFiles() {
137+
assertRootsResolvable();
75138
const files = [];
76-
for (const r of ROOTS) { try { walk(r, files); } catch {} }
139+
for (const r of ROOTS) walk(r, files);
77140
return files;
78141
}
79142

@@ -151,6 +214,38 @@ function selfTest() {
151214
expect('defineX factory form passes', violations.some((v) => v.file === 'skills/legit/SKILL.md'), false);
152215
expect('non-ts fence and prose pass', violations.some((v) => v.file === 'content/docs/ui/pages.mdx'), false);
153216
expect('total violations', violations.length, 2);
217+
218+
// --- Reverse proof for the dead-root hard error (#4916), made permanent. ---
219+
// Everything above ran green over a tree where all three roots resolve. That
220+
// observation is worth nothing on its own: the defect being fixed here is a
221+
// gate that goes green *because* it could not reach a root. So break one root
222+
// the way a rename breaks it in the real repo, require red, require the red to
223+
// name the root that died and not the survivors, then restore it and require
224+
// green again. Red-then-green, in the same run, every run.
225+
const renamedRoot = join(dir, '.claude-renamed-by-self-test');
226+
renameSync(join(dir, '.claude'), renamedRoot);
227+
let deadErr = null;
228+
try { collectFiles(); } catch (err) { deadErr = err; }
229+
renameSync(renamedRoot, join(dir, '.claude'));
230+
231+
expect('a renamed ROOT throws instead of quietly scanning less', deadErr instanceof DeadRootError, true);
232+
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);
234+
235+
// A ROOT that exists but is not a directory is dead in the same way: the old
236+
// `catch {}` swallowed its ENOTDIR exactly as it swallowed ENOENT.
237+
renameSync(join(dir, 'skills'), join(dir, 'skills-renamed-by-self-test'));
238+
writeFileSync(join(dir, 'skills'), 'not a directory');
239+
let notDirErr = null;
240+
try { collectFiles(); } catch (err) { notDirErr = err; }
241+
rmSync(join(dir, 'skills'));
242+
renameSync(join(dir, 'skills-renamed-by-self-test'), join(dir, 'skills'));
243+
244+
expect('a ROOT that is a file is dead too', notDirErr?.dead?.[0]?.reason ?? '<none>', 'exists but is not a directory');
245+
246+
// ...and restoring both roots restores the green, so the red above was caused
247+
// by the broken root and nothing else.
248+
expect('restoring the roots makes the scan green again', collectFiles().length, files.length);
154249
} finally {
155250
process.chdir(cwd);
156251
rmSync(dir, { recursive: true, force: true });
@@ -160,13 +255,29 @@ function selfTest() {
160255
console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`);
161256
process.exit(1);
162257
}
163-
console.log('✓ check-doc-authoring self-test: scope wiring (.claude in, .claude/worktrees out) and detection both hold.');
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.');
164259
}
165260

166261
function main() {
167262
if (process.argv.includes('--self-test')) return selfTest();
168263

169-
const files = collectFiles();
264+
let files;
265+
try {
266+
files = collectFiles();
267+
} catch (err) {
268+
if (!(err instanceof DeadRootError)) throw err;
269+
console.error(`\n✗ doc authoring guard: declared ROOT(s) do not resolve, so the scan would have been silently narrower:\n`);
270+
for (const d of err.dead) console.error(` ${d.root}${d.reason}`);
271+
console.error(
272+
`\nEvery entry in ROOTS (scripts/check-doc-authoring.mjs) must be a directory in the checkout,` +
273+
`\nand this check runs from the repo root. If a corpus directory was renamed or moved, update` +
274+
`\nROOTS to follow it; if it was deleted, remove the entry deliberately. Do NOT restore a` +
275+
`\ntolerant skip: this used to be \`catch {}\`, and a dead root simply shrank the reported file` +
276+
`\ncount while the gate kept printing green (#4916).\n`,
277+
);
278+
process.exit(1);
279+
return;
280+
}
170281
const violations = files.flatMap((file) => findViolations(readFileSync(file, 'utf8'), file));
171282

172283
if (violations.length === 0) {

0 commit comments

Comments
 (0)