Files
Genarrative/scripts/check-doc-index.mjs
T
kdletters a01709c55a 规范化历史文档并接入文档索引门禁
新增文档生命周期分类索引与可见状态头

补充规范驱动开发模板和仓库级 skill

新增文档索引检查并接入 lint

修复文档入口链接并纳入现役专题
2026-09-13 14:54:20 +08:00

111 lines
3.9 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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} 份 Markdowncurrent=${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;
}
}