Skip to content

Commit 8713bc1

Browse files
committed
ci(dx): 两个发现型闸门补「发现数」非空断言 —— 语料/驱动轴静默蒸发即红 (#4932)
两处都靠「扫描发现目标 → 逐个检查」工作,而空集合天然通过所有检查。#4932 正文点的两处 `catch {}` 在 origin/main 上已经不在了(#4916 / #4930),留下的 是同一族里下限断言这一半:发现环节成功、发现结果为空,仍然报绿。 check-doc-authoring:每个 ROOT 必须至少产出一个 .md/.mdx。#4916 的 assertRootsResolvable 只看得见「根不是目录」;根还在、语料搬走了(子树迁出、 SKIP_PATHS 收得更宽、扩展名换了)时它是满意的,walk 少收一批,打印的计数 静默变小 —— `✓ 362 files clean` 与 `✓ 0 files clean` 对读者是同一句话,对 CI 是同一个退出码。下限按根算而不是按总数:总数会被还有文件的那个根托住 (`.claude` 一个就够),而「语料被读了一部分」正是这道闸不该往语料有利方向 解释的判定。断言放在 collectFiles 里,self-test 因此驱动不变量本身而不是它的 代理。不引入需要维护的高水位棘轮 —— 阈值只有「每个声明的根 ≥ 1 个文件」, 由本次 walk 现算。 check-driver-conformance:DISCOVERED 的零下限本来就在(#4363),补的是它看不见 的那一半 —— 整根蒸发会红,单行蒸发不会。把 packages/drivers/driver-sql 改名成 sql,包照样构建、照样测试、照样发布,只有这道闸丢了它,而且丢成「少一行的 矩阵 + 绿」。所以发现改为**完备**:DRIVERS_DIR 下每个条目要么是发现到的 driver,要么不是目录,要么在这里按名报红(unnamed / manifestless)。今天这 一类改名恰好会被 RECONCILED 抓到,但只在台账非空时成立,而空台账是本闸的 预期稳态(#5590#5701 之间就是空的)—— 那是巧合,不是机制。顺带收掉 #4930 漏下的一处 swallow:manifest 探测的 `catch { return false; }` 会把任何 读取失败答成「那就不是 driver」,现在只有 ENOENT 才是该过滤器要问的问题。 反向验证方向先定后跑,三种方向都实测: - 语料整体消失(四个根都在、都空):旧 `✓ 0 files clean` exit 0 → 新 exit 1。 - 单根蒸发(content 只剩非 markdown):旧 `✓ 3 files clean` exit 0 → 新 exit 1, 只点名 content 并报出总数 3。 - 根被改名(#4916 原案):旧红、新红 —— 方向不变,这一半没有被削弱。 - driver-sql 改名为 sql:今天台账非空,所以**颜色不翻**,变的是诊断 —— 旧只报 RECONCILED(怪台账,点错原因),新多报一条 DISCOVERED 点名 packages/drivers/sql。颜色真正会翻的是空台账稳态,已在 self-test 里按 discoveredErrors 的判定钉死(旧实现在同一份合成树上 discovered=[driver-a]、 DISCOVERED errors=0,绿)。 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GX3sL71LFq8m2usg6VqTSE
1 parent 7357130 commit 8713bc1

2 files changed

Lines changed: 294 additions & 46 deletions

File tree

scripts/check-doc-authoring.mjs

Lines changed: 120 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,28 @@
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.
6587
import {
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
*/
183228
function 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', /\.claude|docs|content/.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

340426
function 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

Comments
 (0)