Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions .changeset/doc-authoring-covers-claude-corpus.md
Original file line numberDiff line numberDiff line change
@@ -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),现存
文件零新增违规。
2 changes: 1 addition & 1 deletion package.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -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",
Expand Down
154 changes: 135 additions & 19 deletions scripts/check-doc-authoring.mjs
Original file line numberDiff line numberDiff line change
Expand Up@@ -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']);

Expand All@@ -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();
Loading