// lib/req-ledger.mjs — 需求台账(增量识别) // 跟踪 docs/01 REQ 卡片 + docs/08 §三 FE 行的内容哈希,识别后续新增 / 变更 / 删除的 // 需求单元,使 Coding 阶段只跑增量、不必整仓重跑(变更项由调用方作废其 req-done tag)。 // // 用法(CLI): // node lib/req-ledger.mjs scan → 打印 JSON {new,changed,removed,unchanged} // node lib/req-ledger.mjs commit → 把当前哈希写入 /.req-ledger.json // node lib/req-ledger.mjs status → 同 scan,但人类可读摘要打到 stderr // 退出码:0 = 成功;2 = 用法 / 路径错误。 // // 程序内:import { computeUnits, loadLedger, diffLedger, writeLedger } from './req-ledger.mjs' // // 台账文件 /.req-ledger.json(随项目提交,机器状态,调用方勿手改): // { "version": 1, "units": { "": { "kind": "req"|"fe", "hash": "" } } } // // 单元(unit)= Router 能据以 resume 的最小完成单位: // - kind=req:后端 REQ 卡片,id = 卡片文件名(去 .md),== req_id // - kind=fe :前端功能行,id = FE-NN(取自 docs/08 §三) import { readFileSync, writeFileSync, existsSync, readdirSync, statSync } from 'node:fs' import { join, basename } from 'node:path' import { createHash } from 'node:crypto' const LEDGER_BASENAME = '.req-ledger.json' const REQ_DIR_REL = join('docs', '01-需求清单') const DOCS08_REL = join('docs', '08-模块任务管理.md') export const ledgerPath = (root) => join(root, LEDGER_BASENAME) // ── 哈希:行尾归一(去 \r + 去行尾空白),整体 trim,sha256 取前 12 位十六进制 ── export function hashContent(text) { const norm = String(text) .split('\n') .map((l) => l.replace(/\r$/, '').replace(/[ \t]+$/, '')) .join('\n') .trim() return createHash('sha256').update(norm, 'utf8').digest('hex').slice(0, 12) } // ── 递归列出目录下所有 .md(用于 docs/01-需求清单)───────────────── function walkMd(dir) { const out = [] let entries try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return out } for (const e of entries) { const full = join(dir, e.name) if (e.isDirectory()) out.push(...walkMd(full)) else if (e.isFile() && e.name.endsWith('.md')) out.push(full) } return out } // ── 提取 docs/01 REQ 卡片单元 ──────────────────────────────────── // 排除 _module.md(模块头)与 index.md(模块索引),其余 .md 文件名(去 .md)== req_id。 export function collectReqUnits(root) { const dir = join(root, REQ_DIR_REL) const units = new Map() // id -> { kind:'req', hash, path } for (const file of walkMd(dir)) { const name = basename(file) if (name === '_module.md' || name === 'index.md') continue const id = name.slice(0, -3) // 去 .md units.set(id, { kind: 'req', hash: hashContent(readFileSync(file, 'utf8')), path: file }) } return units } // ── 提取 docs/08 §三 的 FE-NN 行 ───────────────────────────────── // §三 从 `## 三、` 起,到下一个 `## ` 或文件末尾止。行形如:- [ ] FE-01 登录页 export function collectFeUnits(root) { const docs08 = join(root, DOCS08_REL) const units = new Map() // id -> { kind:'fe', hash, line } if (!existsSync(docs08)) return units const lines = readFileSync(docs08, 'utf8').split('\n') let inSec3 = false for (const raw of lines) { const line = raw.replace(/\r$/, '') if (/^##\s+三[、.]/.test(line)) { inSec3 = true; continue } if (inSec3 && /^##\s/.test(line)) break // 进入下一 ## 章节,§三 结束 if (!inSec3) continue const m = line.match(/^\s*-\s*\[.\]\s*(FE-\d+)\b(.*)$/) if (!m) continue const id = m[1] const text = (id + ' ' + m[2].trim()).trim() units.set(id, { kind: 'fe', hash: hashContent(text), line: text }) } return units } // ── 当前全部单元(req + fe):id -> { kind, hash } ───────────────── export function computeUnits(root) { const out = new Map() for (const [id, u] of collectReqUnits(root)) out.set(id, { kind: u.kind, hash: u.hash }) for (const [id, u] of collectFeUnits(root)) out.set(id, { kind: u.kind, hash: u.hash }) return out } // ── 台账读写 ───────────────────────────────────────────────────── export function loadLedger(root) { const p = ledgerPath(root) if (!existsSync(p)) return null try { const j = JSON.parse(readFileSync(p, 'utf8')) if (!j || typeof j !== 'object' || !j.units) return { version: 1, units: {} } return j } catch { return { version: 1, units: {} } } } export function writeLedger(root, units) { const obj = { version: 1, units: {} } // 稳定排序,保证 git diff 可读 for (const id of [...units.keys()].sort()) { const u = units.get(id) obj.units[id] = { kind: u.kind, hash: u.hash } } writeFileSync(ledgerPath(root), JSON.stringify(obj, null, 2) + '\n', 'utf8') return obj } // ── 对比当前单元 vs 台账 → {new,changed,removed,unchanged} ───────── // 每项为 { id, kind } 数组;台账缺失(null)时调用方应先 commit 建基线(此处一律算 new)。 export function diffLedger(ledger, units) { const stored = (ledger && ledger.units) || {} const res = { new: [], changed: [], removed: [], unchanged: [] } for (const [id, u] of units) { const prev = stored[id] if (!prev) res.new.push({ id, kind: u.kind }) else if (prev.hash !== u.hash) res.changed.push({ id, kind: u.kind }) else res.unchanged.push({ id, kind: u.kind }) } for (const id of Object.keys(stored)) { if (!units.has(id)) res.removed.push({ id, kind: stored[id].kind }) } for (const k of ['new', 'changed', 'removed', 'unchanged']) { res[k].sort((a, b) => a.id.localeCompare(b.id)) } return res } // ── CLI ───────────────────────────────────────────────────────── function isMain() { try { return process.argv[1] && import.meta.url === new URL(`file://${process.argv[1].replace(/\\/g, '/')}`).href || (process.argv[1] && process.argv[1].endsWith('req-ledger.mjs')) } catch { return false } } if (isMain()) { const [cmd, root] = process.argv.slice(2) if (!cmd || !root) { process.stderr.write('用法: node req-ledger.mjs \n') process.exit(2) } if (!existsSync(root) || !statSync(root).isDirectory()) { process.stderr.write(`错误: projectRoot 不是目录: ${root}\n`) process.exit(2) } const units = computeUnits(root) if (cmd === 'commit') { const obj = writeLedger(root, units) process.stdout.write(JSON.stringify({ committed: Object.keys(obj.units).length, path: ledgerPath(root) }) + '\n') process.exit(0) } if (cmd === 'scan' || cmd === 'status') { const ledger = loadLedger(root) const diff = diffLedger(ledger, units) if (cmd === 'status') { const fmt = (arr) => arr.map((x) => `${x.id}(${x.kind})`).join(', ') || '无' process.stderr.write( `台账: ${ledger ? '已存在' : '缺失(需先 commit 建基线)'}\n` + ` 新增: ${fmt(diff.new)}\n 变更: ${fmt(diff.changed)}\n` + ` 删除: ${fmt(diff.removed)}\n 未变: ${diff.unchanged.length} 项\n`, ) } process.stdout.write(JSON.stringify({ ledgerExists: !!ledger, ...diff }) + '\n') process.exit(0) } process.stderr.write(`未知命令: ${cmd}\n`) process.exit(2) }