req-ledger.mjs 7.73 KB
// lib/req-ledger.mjs — 需求台账(增量识别)
// 跟踪 docs/01 REQ 卡片 + docs/08 §三 FE 行的内容哈希,识别后续新增 / 变更 / 删除的
// 需求单元,使 Coding 阶段只跑增量、不必整仓重跑(变更项由调用方作废其 req-done tag)。
//
// 用法(CLI):
//   node lib/req-ledger.mjs scan   <root>   → 打印 JSON {new,changed,removed,unchanged}
//   node lib/req-ledger.mjs commit <root>   → 把当前哈希写入 <root>/.req-ledger.json
//   node lib/req-ledger.mjs status <root>   → 同 scan,但人类可读摘要打到 stderr
//   退出码:0 = 成功;2 = 用法 / 路径错误。
//
// 程序内:import { computeUnits, loadLedger, diffLedger, writeLedger } from './req-ledger.mjs'
//
// 台账文件 <root>/.req-ledger.json(随项目提交,机器状态,调用方勿手改):
//   { "version": 1, "units": { "<id>": { "kind": "req"|"fe", "hash": "<sha256-12>" } } }
//
// 单元(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 <scan|commit|status> <projectRoot>\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)
}