#!/usr/bin/env node // ============================================================= // Backlog 已完成记录索引生成器(Node,零依赖) // // 作用:解析 COMPLETED.md 的条目标题,生成「日期 | 编号 | 条目」倒序索引表, // 写回文件头部的 … 标记之间。 // // 背景:COMPLETED.md 只增不减(防重复处理的归档),全量整读代价高。 // 本索引把它变成「按编号/关键词一次命中」的检索入口。 // // 用法(在项目根目录执行): // node docs/backlog/backlog-index.mjs # 刷新同目录 COMPLETED.md 的索引 // node docs/backlog/backlog-index.mjs --check # 只校验索引是否最新(CI 可用) // node docs/backlog/backlog-index.mjs <文件> [--check] // node docs/backlog/backlog-index.mjs --level=3 # 条目标题为 ### 的文件(如 team-base) // // 说明:条目标题级默认为 `##`;若文件用 `###`,首次运行加 --level=3, // 脚本会把级别写入 INDEX:BEGIN 标记,之后无需再传(--check 也自动读取)。 // ★ 本文件(shell-kit/tool-backlog-index.mjs)为 canonical 版本; // 各项目在 docs/backlog/backlog-index.mjs 放置副本,改动请同步回本文件。 // ============================================================= import { readFileSync, writeFileSync, existsSync } from "node:fs"; import { fileURLToPath } from "node:url"; import { dirname, join, isAbsolute, resolve } from "node:path"; const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)); const END_MARKER = ""; const BEGIN_RE = //; const FULL_RE = /[\s\S]*?/; const DATE_RE = /(\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?)/; const TAIL_DATE_PAREN_RE = /((\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2})?))\s*$/; /** 按 GitHub 风格生成标题锚点。 */ function githubAnchor(title) { let out = ""; for (const ch of title.toLowerCase()) { if (ch === " " || ch === "-" || ch === "_") { out += ch; } else if (/[\p{L}\p{N}\p{M}]/u.test(ch)) { out += ch; } } return out.replace(/ /g, "-"); } /** 解析条目标题(跳过围栏代码块与索引小节本身)。 */ function parseEntries(text, level) { const prefix = "#".repeat(level) + " "; const entries = []; let inFence = false; for (const line of text.split("\n")) { const t = line.trim(); if (/^(?:```+|~~~+)/.test(t)) { inFence = !inFence; continue; } if (inFence) continue; if (!line.startsWith(prefix) || line[level] === "#") continue; const title = line.slice(prefix.length).trim(); if (title.startsWith("📇")) continue; const m = title.match(DATE_RE); const date = m ? m[1].replace("T", " ") : ""; let clean = title.replace(TAIL_DATE_PAREN_RE, "").trim(); let num = ""; const lead = clean.match(/^(\d+)\.\s+/); if (lead) { num = "#" + lead[1]; clean = clean.replace(/^(\d+)\.\s+/, "").trim(); } else { num = [...title.matchAll(/#(\d+)/g)].map((x) => "#" + x[1]).join(" "); } entries.push({ date, num, title: clean, anchor: githubAnchor(title) }); } return entries; } /** 渲染索引块(含 BEGIN/END 标记)。 */ function renderIndex(entries, level) { const begin = ""; const head = [ begin, "", "## 📇 索引(按日期倒序|检索入口)", "", "> **检索约定**:本文件仅用于**按编号 / 关键词检索**(如 `grep '#42'` 或搜标题关键词),**禁止整读**(只增不减的归档)。", "> 索引由 [`backlog-index.mjs`](backlog-index.mjs) 自动生成;新增条目后运行 `node docs/backlog/backlog-index.mjs` 刷新,`--check` 校验。", "> 编号与 [BACKLOG.md](BACKLOG.md) 共用唯一连续编号;无编号条目为日常交付/工程记录。早期条目标题未记录日期,集中在表尾(日期列为 `—`)。", "", "| 日期 | 编号 | 条目 |", "|------|------|------|", ]; const rows = entries.map((e) => { const date = e.date ? e.date.slice(0, 10) : "—"; const num = e.num || "—"; const title = e.title.replace(/\|/g, "\\|"); return `| ${date} | ${num} | [${title}](#${e.anchor}) |`; }); return [...head, ...rows, END_MARKER].join("\n"); } /** 生成新文件内容。 */ function build(text, level) { const entries = parseEntries(text, level); // 按日期倒序(同日保持文件内原相对顺序;JS sort 稳定) entries.sort((a, b) => (a.date < b.date ? 1 : a.date > b.date ? -1 : 0)); const index = renderIndex(entries, level); const full = text.match(FULL_RE); let head, tail; if (full) { head = text.slice(0, full.index); tail = text.slice(full.index + full[0].length); } else { const i = text.indexOf("\n---\n"); if (i === -1) { console.error("❌ 未找到首次插入位置(缺少 `---` 分隔符)"); process.exit(2); } head = text.slice(0, i); tail = text.slice(i); } const result = head.replace(/\n+$/, "") + "\n\n" + index + "\n\n" + tail.replace(/^\n+/, ""); return result.endsWith("\n") ? result : result + "\n"; } function main() { const args = process.argv.slice(2); let check = false; let level = null; let target = null; for (const a of args) { if (a === "--check") check = true; else if (a.startsWith("--level=")) level = Number.parseInt(a.slice(8), 10); else if (a.startsWith("--")) { console.error(`❌ 未知参数:${a}`); process.exit(2); } else target = a; } let file = target ? isAbsolute(target) ? target : resolve(process.cwd(), target) : join(SCRIPT_DIR, "COMPLETED.md"); if (existsSync(file) && !file.endsWith(".md")) file = join(file, "COMPLETED.md"); let text; try { text = readFileSync(file, "utf8"); } catch { console.error(`❌ 无法读取文件:${file}`); process.exit(2); } // 级别:CLI 优先,其次读现有标记,最后默认 2 if (level === null) { const m = text.match(BEGIN_RE); const lm = m && m[1] && m[1].match(/level=(\d+)/); level = lm ? Number.parseInt(lm[1], 10) : 2; } const next = build(text, level); if (check) { if (next !== text) { console.error("❌ 索引不是最新,请运行:node docs/backlog/backlog-index.mjs"); process.exit(1); } console.log("✅ 索引已是最新"); return; } if (next === text) { console.log("✅ 索引无变化"); return; } writeFileSync(file, next, "utf8"); console.log(`✅ 已刷新索引(${parseEntries(next, level).length} 条)`); } main();