req-ledger.mjs
7.73 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
// 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)
}