import fs from 'node:fs'; import path from 'node:path'; const root = process.cwd(); const docsRoot = path.join(root, 'docs'); const readmePath = path.join(docsRoot, 'README.md'); const indexPath = path.join( docsRoot, '【协作规范】文档生命周期与现状索引-2026-09-12.md', ); const read = (file) => fs.readFileSync(file, 'utf8'); const rel = (file) => path.relative(root, file).replaceAll(path.sep, '/'); const resolveDocPath = (value) => { const decoded = decodeURIComponent(value.replaceAll('\\', '/')); const withoutPrefix = decoded.startsWith('./') ? decoded.slice(2) : decoded; const target = path.normalize(path.join(docsRoot, withoutPrefix)); const relative = path.relative(docsRoot, target); if (relative.startsWith('..') || path.isAbsolute(relative)) { throw new Error(`docs/README.md 链接越过 docs 根目录: ${value}`); } return target; }; const readme = read(readmePath); const index = read(indexPath); const current = new Set(['docs/README.md', 'docs/project-memory/README.md']); for (const match of readme.matchAll(/\]\((?:<)?([^)>]+)(?:>)?\)/g)) { const value = match[1]; if ( value.startsWith('http') || value.includes('#') || !(value.endsWith('.md') || value.endsWith('.json')) ) continue; const file = resolveDocPath(value); if (!fs.existsSync(file)) { throw new Error(`docs/README.md 链接不存在: ${value}`); } if (value.endsWith('.md')) current.add(rel(file)); } for (const file of fs.readdirSync( path.join(docsRoot, 'project-memory', 'shared-memory'), )) { if (file.endsWith('.md')) current.add(`docs/project-memory/shared-memory/${file}`); } const sectionPaths = (heading, nextHeading) => { const body = index.split(heading, 2)[1]?.split(nextHeading, 2)[0] ?? ''; return new Set( [...body.matchAll(/^- `([^`]+\.md)`$/gm)].map((match) => match[1]), ); }; const historical = sectionPaths('## 历史集合', '## 待复核集合'); const review = sectionPaths('## 待复核集合', '## 开放事项与活动计划'); for (const file of historical) { if (review.has(file)) throw new Error(`文件同时出现在 historical 和 review: ${file}`); } const allDocs = [...walkMarkdown(docsRoot)].map(rel); const classified = new Set([...current, ...historical, ...review]); for (const file of [...historical, ...review]) { const absolute = path.join(root, file); if (!fs.existsSync(absolute)) throw new Error(`索引中的文件不存在: ${file}`); const body = read(absolute).slice(0, 1200); const expected = historical.has(file) ? 'historical' : 'review'; if (!body.includes(`文档状态:\`${expected}\``)) { throw new Error(`${file} 缺少 ${expected} 状态头`); } if (current.has(file)) throw new Error(`${file} 同时被 current 和 ${expected} 分类`); } for (const file of current) { if (!file.endsWith('.md')) continue; if (file === rel(indexPath)) continue; const body = read(path.join(root, file)).slice(0, 1200); if (/^> 文档状态:`(?:historical|review)`/m.test(body)) { throw new Error( `${file} 已进入 current,但仍保留 historical/review 状态头`, ); } } for (const file of allDocs) { if (file.includes('/plans/') || file.includes('/todos/')) continue; if (!classified.has(file)) throw new Error(`文档未分类: ${file}`); } for (const file of [...historical, ...review]) { if (!allDocs.includes(file)) throw new Error(`索引包含非 Markdown 文件: ${file}`); } console.log( `文档索引检查通过:${allDocs.length} 份 Markdown,current=${current.size},historical=${historical.size},review=${review.size}`, ); function* walkMarkdown(directory) { for (const entry of fs.readdirSync(directory, { withFileTypes: true })) { const file = path.join(directory, entry.name); if (entry.isDirectory()) yield* walkMarkdown(file); else if (entry.isFile() && entry.name.endsWith('.md')) yield file; } }