Преглед на файлове

feat(tool-backlog-index): 新增 Backlog 已完成记录索引生成器(Node,零依赖)

- 解析 COMPLETED.md 条目标题生成「日期/编号/条目」索引,写回 INDEX:BEGIN/END 标记
- 支持 --check 校验、--level=N 适配 ### 级标题
- README 工具脚本登记
b преди 1 месец
родител
ревизия
f022d8d40e
променени са 2 файла, в които са добавени 194 реда и са изтрити 0 реда
  1. 1 0
      README.md
  2. 193 0
      tool-backlog-index.mjs

+ 1 - 0
README.md

@@ -12,6 +12,7 @@
 
 ## 工具脚本
 
+- `tool-backlog-index.mjs` : Backlog 已完成记录(COMPLETED.md)检索索引生成器(Node,零依赖;复制为项目 `docs/backlog/backlog-index.mjs` 使用)
 - `tool-import-sql.sh` : 导入 SQL 文件
 - `tool-merge-sql.sh` : 合并 SQL 文件
 - `tool-trunk-checker.sh` : 代码 质量检查

+ 193 - 0
tool-backlog-index.mjs

@@ -0,0 +1,193 @@
+#!/usr/bin/env node
+// =============================================================
+// Backlog 已完成记录索引生成器(Node,零依赖)
+//
+// 作用:解析 COMPLETED.md 的条目标题,生成「日期 | 编号 | 条目」倒序索引表,
+//       写回文件头部的 <!-- INDEX:BEGIN --> … <!-- INDEX:END --> 标记之间。
+//
+// 背景: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 = "<!-- INDEX:END -->";
+const BEGIN_RE = /<!-- INDEX:BEGIN(?:\s+([^>]*?))?\s*-->/;
+const FULL_RE = /<!-- INDEX:BEGIN[^>]*-->[\s\S]*?<!-- INDEX:END -->/;
+
+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 =
+    "<!-- INDEX:BEGIN" + (level !== 2 ? ` level=${level}` : "") + " -->";
+  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();