Skip to content

Commit 3edff5c

Browse files
fix(scripts): check:doc-authoring 覆盖 .claude 语料,worktrees 进 SKIP_PATHS (#4913) (#4915)
ROOTS 此前是 ['skills', 'content'],即顶层 skills/,不含 .claude/。而 .claude/skills、.claude/agents 是 agent 每个会话都会加载并照抄的语料 —— 一个 绕开 defineX() 工厂的裸 metadata 字面量出现在那里,教坏 agent 的效果与出现在 published skills/ 完全一样(#2035 / ADR-0059)。范围写 .claude 而非 .claude/skills,下一个子目录加进来时自动被覆盖。 配套两处: - 新增 SKIP_PATHS,把 .claude/worktrees 整棵子树排除。walker 是 readdirSync 而非 git ls-files,.gitignore 对它无效;不排除就会走进并行 agent 的 worktree (整个仓库的副本),报出与本分支无关的违规。按路径而非目录名排除,免得误伤 语料里同名的合法目录。 - 新增 --self-test:在临时目录里用真实 walker 从真实 ROOTS 走一遍,断言 .claude/** 进得去、.claude/worktrees/** 进不去、SKIP_FILES 仍然生效。 .claude 下当前含 ts 围栏代码块的文件是 0,加进 ROOTS 后门禁照样是绿的, 而「加对了」与「加了仍然扫不到」从外部看一模一样(#4690 / #4804 / #4835 / #4868 / #4890 同族),所以把反向证明折成常驻断言。 扫描文件数 215 → 219,现存文件零新增违规。 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ Co-authored-by: Claude <noreply@anthropic.com>
1 parent 049686a commit 3edff5c

3 files changed

Lines changed: 165 additions & 20 deletions

File tree

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
---
2+
---
3+
4+
chore(scripts): `check:doc-authoring` 的扫描范围加入 `.claude`,并把范围接线折成常驻 `--self-test`。
5+
6+
这条检查只做一件很窄的事:在 ` ```ts ` / ` ```tsx ` 围栏代码块里匹配
7+
`export const X: <16 个 factory 域之一>[Input] = {` 这种绕开 `defineX()` 工厂的裸
8+
metadata 字面量(#2035 / ADR-0059)。它管的是代码样例的正确性,不是文风 —— 所以
9+
「已发布文档的写作规范是否适用于内部 agent 文件」这个顾虑对它并不成立。
10+
11+
`ROOTS` 此前是 `['skills', 'content']`,即**顶层** `skills/`。而 `.claude/`
12+
(skills、agent 定义、workflows)是 agent 每个会话都会加载并照抄的语料 —— 脚本自己
13+
的文件头写着 *Skills are the corpus AI authors from, so a bad sample there is worse
14+
than one in app code*,这句话对 `.claude/` 只会更成立。范围写 `.claude` 而非
15+
`.claude/skills`,下一个子目录加进来时自动被覆盖。
16+
17+
两处配套:
18+
19+
- `.claude/worktrees/`(并行 agent 的 per-task worktree 落点,`.gitignore` 已声明)
20+
进新的 `SKIP_PATHS`。walker 是 `readdirSync` 不是 `git ls-files`,`.gitignore`
21+
拦不住它,不排除就会走进整个仓库的副本,报出与本分支无关的违规。
22+
- 新增 `--self-test`:在临时目录里用真实 walker 从真实 `ROOTS` 走一遍,断言
23+
`.claude/**` 进得去、`.claude/worktrees/**` 进不去。`.claude` 下当前含 ts 围栏
24+
代码块的文件为 0,加进 ROOTS 后门禁照样是绿的 —— 而「加对了」和「加了仍然扫不到」
25+
从外部看一模一样(#4690 / #4804 / #4835 / #4868 / #4890 同族)。自检把这条反向
26+
证明变成常驻断言,而不是一次性验证。
27+
28+
纯 tooling,不发版。扫描文件数 215 → 219(新增的 4 个 `.claude` markdown),现存
29+
文件零新增违规。

‎package.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,7 @@
3333
"check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs",
3434
"check:i18n-coverage": "node scripts/check-i18n-coverage.mjs",
3535
"check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs",
36-
"check:doc-authoring": "node scripts/check-doc-authoring.mjs",
36+
"check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs",
3737
"check:role-word": "node scripts/check-role-word.mjs",
3838
"check:adr-anchors": "node scripts/check-adr-anchors.mjs",
3939
"check:org-identifier": "node scripts/check-org-identifier.mjs",

‎scripts/check-doc-authoring.mjs‎

Lines changed: 135 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,37 @@
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'];
2034
const 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.
2248
const 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
3157
const FENCE_OPEN = /^```(?:ts|typescript|tsx)\s*$/;
3258
const FENCE_CLOSE = /^```\s*$/;
3359

60+
const posix = (p) => p.split(sep).join('/');
61+
3462
function 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 (/\.mdx?$/.test(e) && !SKIP_FILES.has(p)) out.push(p);
69+
else if (/\.mdx?$/.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

Comments
 (0)