From f511965a631446a0a6078d7478caf32b8857ec16 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:52:51 +0000 Subject: [PATCH] =?UTF-8?q?fix(scripts):=20check:doc-authoring=20=E8=A6=86?= =?UTF-8?q?=E7=9B=96=20.claude=20=E8=AF=AD=E6=96=99,worktrees=20=E8=BF=9B?= =?UTF-8?q?=20SKIP=5FPATHS=20(#4913)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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,现存文件零新增违规。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../doc-authoring-covers-claude-corpus.md | 29 ++++ package.json | 2 +- scripts/check-doc-authoring.mjs | 154 +++++++++++++++--- 3 files changed, 165 insertions(+), 20 deletions(-) create mode 100644 .changeset/doc-authoring-covers-claude-corpus.md diff --git a/.changeset/doc-authoring-covers-claude-corpus.md b/.changeset/doc-authoring-covers-claude-corpus.md new file mode 100644 index 0000000000..d6b8df73cf --- /dev/null +++ b/.changeset/doc-authoring-covers-claude-corpus.md @@ -0,0 +1,29 @@ +--- +--- + +chore(scripts): `check:doc-authoring` 的扫描范围加入 `.claude`,并把范围接线折成常驻 `--self-test`。 + +这条检查只做一件很窄的事:在 ` ```ts ` / ` ```tsx ` 围栏代码块里匹配 +`export const X: <16 个 factory 域之一>[Input] = {` 这种绕开 `defineX()` 工厂的裸 +metadata 字面量(#2035 / ADR-0059)。它管的是代码样例的正确性,不是文风 —— 所以 +「已发布文档的写作规范是否适用于内部 agent 文件」这个顾虑对它并不成立。 + +`ROOTS` 此前是 `['skills', 'content']`,即**顶层** `skills/`。而 `.claude/` +(skills、agent 定义、workflows)是 agent 每个会话都会加载并照抄的语料 —— 脚本自己 +的文件头写着 *Skills are the corpus AI authors from, so a bad sample there is worse +than one in app code*,这句话对 `.claude/` 只会更成立。范围写 `.claude` 而非 +`.claude/skills`,下一个子目录加进来时自动被覆盖。 + +两处配套: + +- `.claude/worktrees/`(并行 agent 的 per-task worktree 落点,`.gitignore` 已声明) + 进新的 `SKIP_PATHS`。walker 是 `readdirSync` 不是 `git ls-files`,`.gitignore` + 拦不住它,不排除就会走进整个仓库的副本,报出与本分支无关的违规。 +- 新增 `--self-test`:在临时目录里用真实 walker 从真实 `ROOTS` 走一遍,断言 + `.claude/**` 进得去、`.claude/worktrees/**` 进不去。`.claude` 下当前含 ts 围栏 + 代码块的文件为 0,加进 ROOTS 后门禁照样是绿的 —— 而「加对了」和「加了仍然扫不到」 + 从外部看一模一样(#4690 / #4804 / #4835 / #4868 / #4890 同族)。自检把这条反向 + 证明变成常驻断言,而不是一次性验证。 + +纯 tooling,不发版。扫描文件数 215 → 219(新增的 4 个 `.claude` markdown),现存 +文件零新增违规。 diff --git a/package.json b/package.json index 55e4f4fa86..3b09b3343f 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "check:i18n": "node scripts/check-i18n-bundles.mjs --self-test && node scripts/check-i18n-bundles.mjs", "check:i18n-coverage": "node scripts/check-i18n-coverage.mjs", "check:nul-bytes": "node scripts/check-nul-bytes.mjs --self-test && node scripts/check-nul-bytes.mjs", - "check:doc-authoring": "node scripts/check-doc-authoring.mjs", + "check:doc-authoring": "node scripts/check-doc-authoring.mjs --self-test && node scripts/check-doc-authoring.mjs", "check:role-word": "node scripts/check-role-word.mjs", "check:adr-anchors": "node scripts/check-adr-anchors.mjs", "check:org-identifier": "node scripts/check-org-identifier.mjs", diff --git a/scripts/check-doc-authoring.mjs b/scripts/check-doc-authoring.mjs index 8d122cfb2b..b044b73d4b 100644 --- a/scripts/check-doc-authoring.mjs +++ b/scripts/check-doc-authoring.mjs @@ -13,11 +13,37 @@ // being wrapped in the `defineX(...)` factory, and fails if it finds one. // // node scripts/check-doc-authoring.mjs -import { readdirSync, readFileSync, statSync } from 'node:fs'; -import { join } from 'node:path'; +// node scripts/check-doc-authoring.mjs --self-test +// +// ## Scope (#4913) +// +// `.claude/` is in scope for the same reason `skills/` is, and more so: the +// published `skills/` corpus is what AI authors *apps* from, while `.claude/` +// (skills, agent definitions, workflows) is the operating manual every agent +// session loads and copies from. A bare literal taught there is copied into app +// code by the next agent that reads it. The root was `['skills', 'content']` +// until #4913 — top-level `skills/` only — so nothing checked the corpus the +// agents themselves read. The root is `.claude`, not `.claude/skills`, so the +// next subdirectory added under it is covered on arrival rather than missed the +// same way twice. +import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, sep } from 'node:path'; -const ROOTS = ['skills', 'content']; +const ROOTS = ['.claude', 'skills', 'content']; const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'references']); +// Whole subtrees skipped by path, not by directory name — a bare name would also +// skip a legitimately-named directory anywhere else in the corpus. +// +// `.claude/worktrees/` is where an agent's per-task git worktree lands in the +// environments that keep them inside the repo (it is gitignored, and AGENTS.md +// Prime Directive #11 makes one per task). This walker is `readdirSync`, not +// `git ls-files`, so .gitignore does not exclude it: without this entry the scan +// descends into a FULL SECOND COPY of the repository per parallel agent, which +// is both slow and — worse — reports violations that belong to some other +// branch's working tree. A gate whose failures are not about your change is a +// gate people learn to ignore. +const SKIP_PATHS = new Set(['.claude/worktrees']); // Generated from spec/frontmatter — not hand-authored, don't police. const SKIP_FILES = new Set(['content/docs/ai/skills-reference.mdx']); @@ -31,40 +57,130 @@ const BARE = new RegExp(`^export const \\w+:\\s*${NS}(?:${DOMAINS})(?:Input)?\\s const FENCE_OPEN = /^```(?:ts|typescript|tsx)\s*$/; const FENCE_CLOSE = /^```\s*$/; +const posix = (p) => p.split(sep).join('/'); + function walk(dir, out) { for (const e of readdirSync(dir)) { if (SKIP_DIRS.has(e)) continue; const p = join(dir, e); + if (SKIP_PATHS.has(posix(p))) continue; const s = statSync(p); if (s.isDirectory()) walk(p, out); - else if (/\.mdx?$/.test(e) && !SKIP_FILES.has(p)) out.push(p); + else if (/\.mdx?$/.test(e) && !SKIP_FILES.has(posix(p))) out.push(p); } } -const files = []; -for (const r of ROOTS) { try { walk(r, files); } catch {} } +/** Every Markdown/MDX file in scope, relative to the current working directory. */ +function collectFiles() { + const files = []; + for (const r of ROOTS) { try { walk(r, files); } catch {} } + return files; +} -const violations = []; -for (const file of files) { - const lines = readFileSync(file, 'utf8').split('\n'); +/** Bare metadata literals inside ts/tsx fenced blocks of one file's source. */ +function findViolations(source, file) { + const out = []; + const lines = source.split('\n'); let inBlock = false; for (let i = 0; i < lines.length; i++) { const ln = lines[i]; if (!inBlock) { if (FENCE_OPEN.test(ln)) inBlock = true; continue; } if (FENCE_CLOSE.test(ln)) { inBlock = false; continue; } - if (BARE.test(ln)) violations.push({ file, line: i + 1, text: ln.trim() }); + if (BARE.test(ln)) out.push({ file: posix(file), line: i + 1, text: ln.trim() }); } + return out; } -if (violations.length === 0) { - console.log(`✓ doc authoring guard: ${files.length} files clean — no bare metadata literals.`); - process.exit(0); +// The reverse proof, made permanent (#4913). `.claude/` currently holds zero ts +// code blocks, so adding it to ROOTS leaves the gate green — which is exactly +// what "added it and it still cannot see the directory" looks like from outside. +// Five defects of that family closed in one week (#4690 / #4804 / #4835 / #4868 +// / #4890): a gate running, green, and structurally unable to reach the thing it +// claims to check. So the wiring is asserted against a real temporary tree — +// walked with the real walker, from the real ROOTS — rather than only the regex. +function selfTest() { + const bare = ['```ts', 'export const dashboard: Page = {', " name: 'dashboard',", '};', '```'].join('\n'); + const bareNs = ['```tsx', 'export const settings: UI.PageInput = {', '};', '```'].join('\n'); + const wrapped = ['```ts', 'export const ok = definePage({', '});', '```'].join('\n'); + const jsFence = ['```js', 'export const dashboard: Page = {', '};', '```'].join('\n'); + const prose = ['Do not write `export const dashboard: Page = {` in app code.'].join('\n'); + + const tree = { + // The whole point of #4913: a violation here must be found. + '.claude/skills/demo/SKILL.md': bare, + '.claude/agents/os-dev.md': bareNs, + // ...and one in another agent's worktree copy must NOT be, or every parallel + // agent's in-flight branch becomes this gate's problem. + '.claude/worktrees/other-agent/skills/demo/SKILL.md': bare, + // Pre-existing roots keep working. + 'skills/legit/SKILL.md': wrapped, + 'content/docs/ui/pages.mdx': [jsFence, prose].join('\n\n'), + // Not Markdown, and an explicitly exempt file. + '.claude/settings.json': '{}', + 'content/docs/ai/skills-reference.mdx': bare, + }; + + const dir = mkdtempSync(join(tmpdir(), 'doc-authoring-selftest-')); + const cwd = process.cwd(); + const failures = []; + const expect = (label, got, want) => { + if (got !== want) failures.push(` ✗ self-test "${label}": expected ${want}, got ${got}`); + }; + + try { + for (const [rel, body] of Object.entries(tree)) { + const full = join(dir, ...rel.split('/')); + mkdirSync(dirname(full), { recursive: true }); + writeFileSync(full, body); + } + process.chdir(dir); + const files = collectFiles().map(posix); + const violations = files.flatMap((f) => findViolations(readFileSync(f, 'utf8'), f)); + + expect('.claude is walked', files.includes('.claude/skills/demo/SKILL.md'), true); + expect('.claude is not limited to skills/', files.includes('.claude/agents/os-dev.md'), true); + expect( + '.claude/worktrees is skipped', + files.some((f) => f.startsWith('.claude/worktrees/')), + false, + ); + expect('SKIP_FILES still applies', files.includes('content/docs/ai/skills-reference.mdx'), false); + expect('markdown files collected', files.length, 4); + expect('bare literal in .claude/skills is a violation', violations.some((v) => v.file === '.claude/skills/demo/SKILL.md'), true); + expect('namespaced Input alias in .claude/agents is a violation', violations.some((v) => v.file === '.claude/agents/os-dev.md'), true); + expect('defineX factory form passes', violations.some((v) => v.file === 'skills/legit/SKILL.md'), false); + expect('non-ts fence and prose pass', violations.some((v) => v.file === 'content/docs/ui/pages.mdx'), false); + expect('total violations', violations.length, 2); + } finally { + process.chdir(cwd); + rmSync(dir, { recursive: true, force: true }); + } + + if (failures.length) { + console.error(`\n✗ check-doc-authoring self-test failed:\n${failures.join('\n')}\n`); + process.exit(1); + } + console.log('✓ check-doc-authoring self-test: scope wiring (.claude in, .claude/worktrees out) and detection both hold.'); } -console.error(`\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n`); -for (const v of violations) { - console.error(` ${v.file}:${v.line}`); - console.error(` ${v.text}`); +function main() { + if (process.argv.includes('--self-test')) return selfTest(); + + const files = collectFiles(); + const violations = files.flatMap((file) => findViolations(readFileSync(file, 'utf8'), file)); + + if (violations.length === 0) { + console.log(`✓ doc authoring guard: ${files.length} files clean — no bare metadata literals.`); + return; + } + + console.error(`\n✗ Bare metadata-literal authoring found in docs/skills (#2035). Use the defineX factory instead:\n`); + for (const v of violations) { + console.error(` ${v.file}:${v.line}`); + console.error(` ${v.text}`); + } + 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`); + process.exit(1); } -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`); -process.exit(1); + +main();