dsh-sa-workbench 0.1.30 → 0.1.32

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { randomUUID } from 'node:crypto'
4
+ import { fingerprint, PmError } from './engine.js'
5
+ import { knowledgeRecord } from './knowledge-model.mjs'
6
+
7
+ export const CANDIDATE_PATH = 'notes/knowledge-candidates.md'
8
+ const MAX_DOCUMENT = 256 * 1024
9
+
10
+ function reject(code, message) {
11
+ throw new PmError(code, message)
12
+ }
13
+
14
+ function text(value, label, limit = 240, required = true) {
15
+ if (typeof value !== 'string' || value.length > limit || (required && !value.trim())) {
16
+ reject('bad_input', `${label}为空或超过长度限制。`)
17
+ }
18
+ return value.trim()
19
+ }
20
+
21
+ function scalar(value, label, required = true) {
22
+ const result = text(value ?? '', label, 240, required)
23
+ if (/[\r\n]/.test(result)) reject('bad_input', `${label}请填写单行文本。`)
24
+ return result
25
+ }
26
+
27
+ function list(value, label) {
28
+ if (!Array.isArray(value) || value.length > 12) reject('bad_input', `${label}请填写数组,最多12项。`)
29
+ return [...new Set(value.map(item => scalar(item, label))) ]
30
+ }
31
+
32
+ async function optional(file) {
33
+ try { return await fs.readFile(file, 'utf8') }
34
+ catch (error) { if (error.code === 'ENOENT') return null; throw error }
35
+ }
36
+
37
+ export async function safePath(root, relative) {
38
+ const absoluteRoot = path.resolve(root)
39
+ const absolute = path.resolve(absoluteRoot, relative)
40
+ if (absolute !== absoluteRoot && !absolute.startsWith(absoluteRoot + path.sep)) reject('bad_path', '路径超出知识目录。')
41
+ // Check ancestors too: configured roots can themselves be redirected.
42
+ const parsed = path.parse(absolute)
43
+ let current = parsed.root
44
+ for (const part of absolute.slice(parsed.root.length).split(path.sep)) {
45
+ current = path.join(current, part)
46
+ const stat = await fs.lstat(current).catch(error => { if (error.code === 'ENOENT') return null; throw error })
47
+ if (!stat) break
48
+ if (stat.isSymbolicLink()) reject('bad_path', '知识目录包含重定向,请使用普通目录。')
49
+ }
50
+ return absolute
51
+ }
52
+
53
+ function knowledgePath(relative) {
54
+ const parts = String(relative).split('/')
55
+ if (parts.length > 2 || !/\.md$/i.test(relative) || parts.some(part =>
56
+ !/^[\p{L}\p{N}][\p{L}\p{N} _().()【】-]*$/u.test(part) || /[ .]$/.test(part) ||
57
+ /^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\.|$)/i.test(part))) {
58
+ reject('bad_path', '知识文件路径需为根目录或一层子目录下的普通 Markdown 文件。')
59
+ }
60
+ return relative
61
+ }
62
+
63
+ export async function atomicWrite(file, content) {
64
+ await fs.mkdir(path.dirname(file), { recursive: true })
65
+ const temporary = file + '.' + randomUUID() + '.tmp'
66
+ try {
67
+ await fs.writeFile(temporary, content, { encoding: 'utf8', flag: 'wx' })
68
+ await fs.rename(temporary, file)
69
+ } finally {
70
+ await fs.unlink(temporary).catch(error => { if (error.code !== 'ENOENT') throw error })
71
+ }
72
+ }
73
+
74
+ function metadataHeader(before, fields) {
75
+ const managed = new Set(Object.keys(fields))
76
+ const previous = before?.match(/^\uFEFF?---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/)
77
+ const retained = []
78
+ let skip = false
79
+ for (const line of (previous?.[1] || '').split(/\r?\n/)) {
80
+ const key = line.match(/^([A-Za-z_][A-Za-z0-9_-]*):/)
81
+ if (key) skip = managed.has(key[1])
82
+ if (!skip && line.trim()) retained.push(line)
83
+ }
84
+ const updated = Object.entries(fields).map(([key, value]) => key + ': ' +
85
+ (Array.isArray(value) ? '[' + value.map(entry => JSON.stringify(entry)).join(', ') + ']' : JSON.stringify(value)))
86
+ return [...retained, ...updated].join('\n')
87
+ }
88
+
89
+ export async function withKnowledgeLock(root, run) {
90
+ const control = await safePath(root, '.sa-knowledge-control')
91
+ await fs.mkdir(control, { recursive: true })
92
+ const file = await safePath(root, '.sa-knowledge-control/write.lock')
93
+ const identity = JSON.stringify({ pid: process.pid, token: randomUUID() })
94
+ let lock
95
+ try { lock = await fs.open(file, 'wx') }
96
+ catch (error) {
97
+ if (error.code !== 'EEXIST') throw error
98
+ const previous = await optional(file)
99
+ let dead = false
100
+ try {
101
+ const owner = JSON.parse(previous)
102
+ if (!Number.isInteger(owner.pid) || owner.pid < 1) throw Error('Invalid lock owner')
103
+ try { process.kill(owner.pid, 0) } catch (probe) { dead = probe.code === 'ESRCH' }
104
+ } catch { /* A malformed lock requires explicit repair. */ }
105
+ if (dead && await optional(file) === previous) {
106
+ await fs.unlink(file)
107
+ return withKnowledgeLock(root, run)
108
+ }
109
+ reject('conflict', '知识库正在保存,请稍后重试。')
110
+ }
111
+ let recorded = false
112
+ try { await lock.writeFile(identity); recorded = true; return await run(control) }
113
+ finally {
114
+ await lock.close()
115
+ if (!recorded || await optional(file) === identity) await fs.unlink(file).catch(error => { if (error.code !== 'ENOENT') throw error })
116
+ }
117
+ }
118
+
119
+ export class KnowledgeWorkflow {
120
+ constructor(pm) { this.pm = pm }
121
+
122
+ async read(relative) {
123
+ const target = knowledgePath(relative)
124
+ const file = (await this.pm.listKnowledge()).find(entry => entry.path === target)
125
+ if (!file) reject('not_found', '知识文件不存在或超出当前枚举范围。')
126
+ if (file.bytes > MAX_DOCUMENT) reject('bad_input', '知识文件超过256KiB,请分拆后再通过助手复核。')
127
+ await safePath(this.pm.knowledgeDir, target)
128
+ const content = await this.pm.readKnowledge(target)
129
+ if (content === null) reject('not_found', '知识文件已移走,请刷新。')
130
+ return { path: target, content, fingerprint: fingerprint(content) }
131
+ }
132
+
133
+ async search(query = '') {
134
+ const files = await this.pm.listKnowledge()
135
+ const terms = String(query).toLocaleLowerCase().split(/\s+/).filter(Boolean)
136
+ const matches = []
137
+ let budget = 4 * 1024 * 1024
138
+ for (const file of files) {
139
+ if (file.bytes > MAX_DOCUMENT || file.bytes > budget) continue
140
+ const content = await this.pm.readKnowledge(file.path)
141
+ if (content === null) continue
142
+ budget -= Buffer.byteLength(content, 'utf8')
143
+ const record = knowledgeRecord(file, content)
144
+ const haystack = (record.title + '\n' + content).toLocaleLowerCase()
145
+ if (!terms.length || terms.some(term => haystack.includes(term))) {
146
+ matches.push({ path: file.path, title: record.title, type: record.kind, domains: record.domains,
147
+ status: record.status, fingerprint: fingerprint(content), excerpt: content.slice(0, 6000) })
148
+ }
149
+ }
150
+ return { matches: matches.slice(0, 12), total: matches.length,
151
+ coverage: 'Markdown根目录与下一层,单文件256KiB、总读取4MiB,最多返回12条。修改目标需重新完整读取原文件。' }
152
+ }
153
+
154
+ async load(id) {
155
+ const { record } = await this.pm.findRequirement(id)
156
+ let document
157
+ try { document = await this.pm.readDocument(id, CANDIDATE_PATH) }
158
+ catch (error) {
159
+ if (error.code === 'ENOENT') return { id, code: record.code, items: [], fingerprint: null }
160
+ throw error
161
+ }
162
+ if (Buffer.byteLength(document.content, 'utf8') > MAX_DOCUMENT) reject('bad_input', '候选文件超过256KiB,请分批提取。')
163
+ const raw = document.content.trim().replace(/^```json\s*\n([\s\S]*?)\n```$/, '$1')
164
+ let batch
165
+ try { batch = JSON.parse(raw) } catch { reject('bad_input', '知识候选格式有误,请让助手按约定保存JSON。') }
166
+ if (batch.schemaVersion !== 1 || !Array.isArray(batch.items) || batch.items.length > 30) reject('bad_input', '知识候选需为schemaVersion 1,最多30条。')
167
+ const identifiers = new Set()
168
+ const items = []
169
+ for (const item of batch.items) {
170
+ if (!item || typeof item !== 'object' || typeof item.id !== 'string' ||
171
+ !/^[a-zA-Z0-9_-]{1,64}$/.test(item.id) || identifiers.has(item.id)) reject('bad_input', '候选编号为空、重复或格式有误。')
172
+ scalar(item.title, '候选标题')
173
+ text(item.body, '候选正文', 60000)
174
+ list(item.domains || [], '候选领域')
175
+ list(item.uncertainties || [], '待确认问题')
176
+ for (const field of ['type', 'owner', 'scope']) scalar(item[field] || '', field, false)
177
+ if (!Array.isArray(item.sources) || !item.sources.length || item.sources.length > 8 ||
178
+ item.sources.some(source => !source || typeof source.path !== 'string' || typeof source.quote !== 'string' || typeof source.fingerprint !== 'string')) {
179
+ reject('bad_input', '候选依据需包含文件、原文和指纹。')
180
+ }
181
+ identifiers.add(item.id)
182
+ const candidateFingerprint = fingerprint(JSON.stringify(item))
183
+ const key = fingerprint(this.pm.root + ':' + id + ':' + item.id + ':' + candidateFingerprint)
184
+ const receiptFile = await safePath(this.pm.knowledgeDir, '.sa-knowledge-control/' + key + '.json')
185
+ const transaction = await optional(receiptFile)
186
+ const saved = transaction ? JSON.parse(transaction) : null
187
+ items.push({ ...item, candidateFingerprint, receipt: saved?.state === 'applied' ? saved.receipt : null,
188
+ pendingReview: saved?.state === 'prepared' ? saved.preview : null })
189
+ }
190
+ return { id, code: record.code, requirementTitle: record.title, archived: !!record.archived,
191
+ sourceDeliveryId: record.sourceDeliveryId, sourceRequirementId: record.sourceRequirementId,
192
+ fingerprint: document.fingerprint, items }
193
+ }
194
+
195
+ async verifySources(id, sources) {
196
+ if (!Array.isArray(sources) || !sources.length || sources.length > 8) reject('bad_input', '每条候选需提供1至8份原文依据。')
197
+ const checked = []
198
+ for (const source of sources) {
199
+ const relative = this.pm.authorizeArtifact(id, source.path)
200
+ if (relative === CANDIDATE_PATH || !/\.md$/i.test(relative)) reject('bad_input', '请引用需求的Markdown原文,候选文件不能作为自身依据。')
201
+ const document = await this.pm.readDocument(id, relative)
202
+ if (document.fingerprint !== source.fingerprint) reject('stale_artifact', `原文 ${relative} 已变化,请重新提取候选。`)
203
+ const quote = text(source.quote, '依据原文', 12000)
204
+ if (!document.content.includes(quote)) reject('bad_input', `依据原文与 ${relative} 内容不匹配。`)
205
+ checked.push({ path: relative, quote, fingerprint: document.fingerprint, version: document.documentVersion })
206
+ }
207
+ return checked
208
+ }
209
+
210
+ async preview(input) {
211
+ const batch = await this.load(input.id)
212
+ if (batch.archived) reject('archived', '历史归档需求只读。')
213
+ if (!batch.fingerprint || batch.fingerprint !== input.candidatesFingerprint) reject('conflict', '候选已变化,请刷新复核。')
214
+ const item = batch.items.find(entry => entry.id === input.itemId)
215
+ if (!item) reject('not_found', '候选不存在。')
216
+ if (item.receipt) reject('conflict', '此候选已入库,请刷新查看回执。')
217
+ if (input.candidateFingerprint !== item.candidateFingerprint) reject('conflict', '候选内容已变化,请刷新复核。')
218
+ const edits = input.edits || item
219
+ const values = {
220
+ title: scalar(edits.title, '标题'), type: scalar(edits.type, '知识类型'),
221
+ domains: list(edits.domains, '领域'), scope: scalar(edits.scope, '适用范围'),
222
+ owner: scalar(edits.owner, '维护人'), body: text(edits.body, '知识正文', 60000),
223
+ uncertainties: list(edits.uncertainties || [], '待确认问题'),
224
+ reviewedBy: scalar(input.reviewedBy, '确认人'),
225
+ }
226
+ if (values.uncertainties.length) reject('bad_input', '请先解决列出的待确认问题,再确认入库。')
227
+ if (values.domains.some(domain => /[,,\[\]"]/.test(domain))) reject('bad_input', '领域名称请使用不含逗号、括号和双引号的短名称。')
228
+ const sources = await this.verifySources(input.id, item.sources)
229
+ const targetPath = knowledgePath(item.targetPath || 'entries/k-' + fingerprint(this.pm.root + ':' + input.id + ':' + item.id).slice(0, 20) + '.md')
230
+ const target = await safePath(this.pm.knowledgeDir, targetPath)
231
+ const targetStat = await fs.stat(target).catch(error => { if (error.code === 'ENOENT') return null; throw error })
232
+ if (targetStat && targetStat.size > MAX_DOCUMENT) reject('bad_input', '目标知识超过256KiB,请分拆后复核。')
233
+ const before = await optional(target)
234
+ const targetFingerprint = before === null ? null : fingerprint(before)
235
+ if (item.targetPath && targetFingerprint !== (item.targetFingerprint ?? null)) reject('conflict', '目标知识已变化,请让助手重新比较差异。')
236
+ if (!item.targetPath && before !== null) reject('conflict', '同一候选已有知识文件,请刷新或以更新方式提取。')
237
+ const context = { root: path.resolve(this.pm.knowledgeDir), requirementRoot: this.pm.root,
238
+ id: input.id, itemId: item.id, candidateFingerprint: item.candidateFingerprint,
239
+ candidatesFingerprint: batch.fingerprint, targetPath, targetFingerprint, values, sources }
240
+ // Independent candidates in the same note can change while a prepared
241
+ // write is recovered. The individual candidate and all evidence stay bound.
242
+ const token = fingerprint(JSON.stringify({ ...context, candidatesFingerprint: null }))
243
+ const fields = { title: values.title, type: values.type, domains: values.domains, status: '已确认',
244
+ scope: values.scope, owner: values.owner, version: 'review-' + token.slice(0, 12),
245
+ source: batch.code + ' ' + sources.map(source => source.path + '@' + source.version).join('、'),
246
+ source_fingerprints: sources.map(source => source.fingerprint),
247
+ reviewed_by: values.reviewedBy, review_id: token }
248
+ if (batch.sourceDeliveryId) fields.source_delivery_id = batch.sourceDeliveryId
249
+ if (batch.sourceRequirementId) fields.source_requirement_id = batch.sourceRequirementId
250
+ const metadata = metadataHeader(before, fields)
251
+ const provenance = sources.map(source => `- ${batch.code} / ${source.path} / ${source.version}\n\n> ${source.quote.replace(/\n/g, '\n> ')}`).join('\n\n')
252
+ const content = '---\n' + metadata + '\n---\n# ' + values.title + '\n\n' + values.body +
253
+ '\n\n## 原文依据\n\n' + provenance + '\n'
254
+ if (Buffer.byteLength(content, 'utf8') > MAX_DOCUMENT) reject('bad_input', '入库内容超过256KiB,请拆成更小的知识条目。')
255
+ return { ...context, token, content, before, operation: before === null ? 'create' : 'update' }
256
+ }
257
+
258
+ async approve(input) {
259
+ if (input.confirmed !== true || !/^[a-f0-9]{64}$/.test(input.token || '') || !/^[a-f0-9]{64}$/.test(input.candidateFingerprint || '')) reject('bad_input', '请完成复核并明确确认入库。')
260
+ return withKnowledgeLock(this.pm.knowledgeDir, async () => {
261
+ const key = fingerprint(this.pm.root + ':' + input.id + ':' + input.itemId + ':' + input.candidateFingerprint)
262
+ const receiptFile = await safePath(this.pm.knowledgeDir, '.sa-knowledge-control/' + key + '.json')
263
+ const saved = await optional(receiptFile)
264
+ let transaction = saved ? JSON.parse(saved) : null
265
+ if (transaction?.state === 'applied') return transaction.receipt
266
+ if (transaction && transaction.preview.token !== input.token) reject('conflict', '这条候选已有待完成的保存,请恢复原复核内容后重试。')
267
+ if (!transaction) {
268
+ const preview = await this.preview(input)
269
+ if (preview.token !== input.token) reject('conflict', '复核内容已变化,请重新预览差异。')
270
+ transaction = { state: 'prepared', preview, acceptedAt: new Date().toISOString() }
271
+ await atomicWrite(receiptFile, JSON.stringify(transaction, null, 2))
272
+ }
273
+ const preview = transaction.preview
274
+ if (preview.root !== path.resolve(this.pm.knowledgeDir)) reject('conflict', '知识库目录已切换,请重新复核。')
275
+ const target = await safePath(this.pm.knowledgeDir, knowledgePath(preview.targetPath))
276
+ const current = await optional(target)
277
+ if (current !== preview.content) {
278
+ const live = await this.load(input.id)
279
+ const fresh = await this.preview({ ...input, candidatesFingerprint: live.fingerprint })
280
+ if (fresh.token !== preview.token || (current === null ? null : fingerprint(current)) !== preview.targetFingerprint) {
281
+ reject('conflict', '原文或目标知识已变化,保存停止,请重新复核。')
282
+ }
283
+ await atomicWrite(target, preview.content)
284
+ }
285
+ const receipt = { path: preview.targetPath, title: preview.values.title, fingerprint: fingerprint(preview.content),
286
+ operation: preview.operation, reviewedBy: preview.values.reviewedBy, acceptedAt: transaction.acceptedAt, token: preview.token }
287
+ await atomicWrite(receiptFile, JSON.stringify({ ...transaction, state: 'applied', receipt }, null, 2))
288
+ return receipt
289
+ })
290
+ }
291
+ }
@@ -0,0 +1,74 @@
1
+ import fs from 'node:fs/promises'
2
+ import { fingerprint, PmError } from './engine.js'
3
+ import { safePath, atomicWrite, withKnowledgeLock } from './knowledge-workflow.mjs'
4
+
5
+ const FILE = '.sa-knowledge-control/products.json'
6
+ const fail = message => { throw new PmError('bad_input', message) }
7
+
8
+ export function validateProducts(input) {
9
+ if (input?.schemaVersion !== 1 || !Array.isArray(input.nodes) || input.nodes.length > 1000) fail('产品目录需为 schemaVersion 1,最多1000个节点。')
10
+ const nodes = input.nodes.map(node => {
11
+ if (!node || typeof node.id !== 'string' || !/^[a-zA-Z0-9_.-]{1,100}$/.test(node.id) ||
12
+ typeof node.name !== 'string' || !node.name.trim() || node.name.length > 120 ||
13
+ !['产品体系', '产品', '子产品', '模块'].includes(node.type) ||
14
+ (node.parentId != null && typeof node.parentId !== 'string')) fail('请检查节点 ID、名称、类型和 parentId。')
15
+ return { id: node.id, name: node.name.trim(), type: node.type, parentId: node.parentId || null }
16
+ })
17
+ const byId = new Map(nodes.map(node => [node.id, node]))
18
+ if (byId.size !== nodes.length) fail('产品节点 ID 不可重复。')
19
+ for (const node of nodes) {
20
+ const visited = new Set([node.id])
21
+ let parent = node.parentId
22
+ while (parent) {
23
+ if (!byId.has(parent)) fail('产品目录有不存在的父节点。')
24
+ if (visited.has(parent)) fail('产品包含关系存在循环。')
25
+ visited.add(parent)
26
+ if (visited.size > 16) fail('产品层级最多16层。')
27
+ parent = byId.get(parent).parentId
28
+ }
29
+ }
30
+ return nodes
31
+ }
32
+
33
+ export class ProductContext {
34
+ constructor(pm) { this.pm = pm }
35
+ async document(relative) {
36
+ if (!(await this.pm.listKnowledge()).some(file => file.path === relative)) fail('知识文档不存在,请刷新。')
37
+ await safePath(this.pm.knowledgeDir, relative)
38
+ const content = await this.pm.readKnowledge(relative)
39
+ if (content === null) fail('知识文档不存在,请刷新。')
40
+ return { path: relative, content, fingerprint: fingerprint(content) }
41
+ }
42
+ async read() {
43
+ const file = await safePath(this.pm.knowledgeDir, FILE)
44
+ let raw
45
+ try { raw = await fs.readFile(file, 'utf8') }
46
+ catch (error) { if (error.code !== 'ENOENT') throw error }
47
+ if (!raw) return { schemaVersion: 1, nodes: [], links: {}, revision: null }
48
+ const data = JSON.parse(raw)
49
+ return { schemaVersion: 1, nodes: validateProducts(data), links: data.links || {}, revision: fingerprint(raw) }
50
+ }
51
+ async save(input) {
52
+ return withKnowledgeLock(this.pm.knowledgeDir, async () => {
53
+ const current = await this.read()
54
+ if (input.revision !== current.revision) throw new PmError('conflict', '目录或关联已变化,请刷新后重试。')
55
+ let nodes = current.nodes, links = { ...current.links }
56
+ if (input.operation === 'import') {
57
+ nodes = validateProducts(input.catalog)
58
+ const ids = new Set(nodes.map(node => node.id))
59
+ if (Object.values(links).some(link => link.nodeIds.some(id => !ids.has(id)))) fail('新目录移除了已关联节点,请先解除关联或保留原 ID。')
60
+ } else if (input.operation === 'link') {
61
+ if (typeof input.path !== 'string' || !/\.md$/i.test(input.path)) fail('请选择 Markdown 知识。')
62
+ // Reuse the existing authorized full read, path checks and document fingerprint.
63
+ const document = await this.document(input.path)
64
+ if (document.fingerprint !== input.documentFingerprint) throw new PmError('conflict', '知识正文已变化,请重新打开后关联。')
65
+ const ids = new Set(nodes.map(node => node.id))
66
+ if (!Array.isArray(input.nodeIds) || input.nodeIds.length > 100 || input.nodeIds.some(id => !ids.has(id))) fail('关联节点不存在或超过100项。')
67
+ links[input.path] = { nodeIds: [...new Set(input.nodeIds)], documentFingerprint: document.fingerprint, updatedAt: new Date().toISOString() }
68
+ } else fail('未知目录操作。')
69
+ const raw = JSON.stringify({ schemaVersion: 1, nodes, links }, null, 2) + '\n'
70
+ await atomicWrite(await safePath(this.pm.knowledgeDir, FILE), raw)
71
+ return { schemaVersion: 1, nodes, links, revision: fingerprint(raw) }
72
+ })
73
+ }
74
+ }
@@ -0,0 +1,276 @@
1
+ import fs from 'node:fs/promises'
2
+ import path from 'node:path'
3
+ import { createHash } from 'node:crypto'
4
+ import { createReadStream } from 'node:fs'
5
+ import { assertRequirementId, fingerprint, loadState, saveState, PmError } from './engine.js'
6
+ import { safePath, atomicWrite, withKnowledgeLock } from './knowledge-workflow.mjs'
7
+ import { knowledgeRecord } from './knowledge-model.mjs'
8
+
9
+ const CONTROL = '.sa-requirement-deletions'
10
+ const REQUEST = /^[a-f0-9-]{36}$/i
11
+ const labels = { workspace: '需求与交付', knowledge: '知识库', prd: '历史交付' }
12
+ const missing = error => { if (error.code !== 'ENOENT') throw error; return null }
13
+
14
+ function hasCode(value, code) {
15
+ if (!code) return false
16
+ const escaped = code.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
17
+ return new RegExp('(^|[^a-zA-Z0-9])' + escaped + '(?=$|[^a-zA-Z0-9])', 'i').test(String(value || ''))
18
+ }
19
+
20
+ async function readOptional(file) {
21
+ return fs.readFile(file, 'utf8').catch(missing)
22
+ }
23
+
24
+ async function tree(root, relative) {
25
+ const absolute = await safePath(root, relative)
26
+ const stat = await fs.lstat(absolute).catch(missing)
27
+ if (!stat) return null
28
+ if (stat.isSymbolicLink()) throw new PmError('bad_path', '删除范围含重定向,请先核对原目录。')
29
+ if (stat.isDirectory()) {
30
+ const children = []
31
+ for (const name of (await fs.readdir(absolute)).sort()) {
32
+ const child = await tree(root, path.join(relative, name))
33
+ if (child) children.push([name, child])
34
+ }
35
+ return { directory: true, children }
36
+ }
37
+ if (!stat.isFile()) throw new PmError('bad_path', '删除范围含非普通文件。')
38
+ const hash = createHash('sha256')
39
+ for await (const chunk of createReadStream(absolute)) hash.update(chunk)
40
+ return { bytes: stat.size, hash: hash.digest('hex') }
41
+ }
42
+
43
+ function fileCount(snapshot) {
44
+ return snapshot.directory ? snapshot.children.reduce((sum, [, child]) => sum + fileCount(child), 0) : 1
45
+ }
46
+
47
+ export class RequirementDeletion {
48
+ constructor(pm) {
49
+ this.pm = pm
50
+ this.roots = () => ({ workspace: path.resolve(pm.root), knowledge: path.resolve(pm.knowledgeDir), prd: path.resolve(pm.prdArchiveDir || path.join(pm.root, 'prd-archive')) })
51
+ this.ready = this.recover()
52
+ }
53
+
54
+ async locked(run) {
55
+ const roots = [...new Set([this.pm.root, this.pm.knowledgeDir, this.pm.deliveryLibrary.root].map(root => path.resolve(root)))].sort()
56
+ const enter = index => index === roots.length ? run() : withKnowledgeLock(roots[index], () => enter(index + 1))
57
+ return enter(0)
58
+ }
59
+
60
+ async scan(id) {
61
+ const state = await loadState(this.pm.root)
62
+ const record = state.requirements[assertRequirementId(id)]
63
+ if (!record) throw new PmError('not_found', '需求已不存在,请刷新清单。')
64
+ const code = String(record.code || '')
65
+ if (code && Object.values(state.requirements).some(other => other.id !== id && other.code === code)) {
66
+ throw new PmError('conflict', '需求编码重复,请先核对重复记录。')
67
+ }
68
+ const roots = this.roots(), targets = []
69
+ async function add(area, relative, kind) {
70
+ const snapshot = await tree(roots[area], relative)
71
+ if (snapshot) targets.push({ area, relative: relative.replaceAll('\\', '/'), kind, snapshot })
72
+ }
73
+ await add('workspace', (record.archived ? 'archive/' : 'requirements/') + id, 'requirement')
74
+ const deliveries = (await this.pm.deliveryLibrary.catalog()).filter(entry => entry.requirementId === id || code && entry.code === code || entry.origin === 'file' && hasCode(entry.legacyName, code))
75
+ for (const entry of deliveries) {
76
+ if (entry.requirementId && entry.requirementId !== id) throw new PmError('conflict', '交付编码与来源需求不一致,请先核对交付记录。')
77
+ if (entry.origin === 'local') await add('workspace', '.sa-deliveries/records/' + entry.id, 'delivery')
78
+ if (entry.origin === 'file') await add('prd', entry.legacyVersion + '/' + entry.legacyName, 'delivery')
79
+ await add('workspace', '.sa-deliveries/candidates/' + entry.id + '.md', 'candidate')
80
+ }
81
+ const deliveryIds = new Set(deliveries.map(entry => entry.id))
82
+ const knowledgePaths = new Set()
83
+ for (const file of await this.pm.listKnowledge()) {
84
+ const absolute = await safePath(roots.knowledge, file.path)
85
+ const content = await fs.readFile(absolute, 'utf8')
86
+ const metadata = knowledgeRecord(file, content)
87
+ const attached = metadata.sourceRequirementId === id || deliveryIds.has(metadata.sourceDeliveryId) ||
88
+ hasCode(metadata.source, code) || hasCode(file.path, code)
89
+ if (!attached) continue
90
+ if (metadata.sourceRequirementId && metadata.sourceRequirementId !== id) throw new PmError('conflict', '知识编码与来源需求不一致,请先核对来源。')
91
+ knowledgePaths.add(file.path)
92
+ await add('knowledge', file.path, 'knowledge')
93
+ }
94
+ const control = await safePath(roots.knowledge, '.sa-knowledge-control')
95
+ const receipts = await fs.readdir(control).catch(error => { if (error.code === 'ENOENT') return []; throw error })
96
+ for (const name of receipts.filter(name => /^[a-f0-9]{64}\.json$/.test(name))) {
97
+ const relative = '.sa-knowledge-control/' + name
98
+ const receipt = JSON.parse(await fs.readFile(await safePath(roots.knowledge, relative), 'utf8'))
99
+ if (receipt.preview?.id === id || deliveryIds.has(receipt.preview?.id) || knowledgePaths.has(receipt.receipt?.path) || knowledgePaths.has(receipt.preview?.targetPath)) {
100
+ await add('knowledge', relative, 'receipt')
101
+ }
102
+ }
103
+ const productsFile = await safePath(roots.knowledge, '.sa-knowledge-control/products.json')
104
+ const productsBefore = await readOptional(productsFile)
105
+ let productsAfter = productsBefore
106
+ if (productsBefore) {
107
+ const products = JSON.parse(productsBefore)
108
+ const linked = [...knowledgePaths].filter(relative => Object.hasOwn(products.links || {}, relative))
109
+ for (const relative of linked) delete products.links[relative]
110
+ if (linked.length) productsAfter = JSON.stringify(products, null, 2) + '\n'
111
+ }
112
+ targets.sort((a, b) => (a.area + '/' + a.relative).localeCompare(b.area + '/' + b.relative))
113
+ const unique = targets.filter((target, index) => !targets.some((parent, parentIndex) => parentIndex !== index &&
114
+ path.resolve(roots[target.area], target.relative).startsWith(path.resolve(roots[parent.area], parent.relative) + path.sep)))
115
+ const result = { id, code, title: record.title, roots, targets: unique, productsBefore, productsAfter,
116
+ totals: { requirementFiles: targets.filter(target => target.kind === 'requirement').reduce((sum, target) => sum + fileCount(target.snapshot), 0),
117
+ deliveries: deliveries.length, knowledge: knowledgePaths.size, cloudReceipts: deliveries.filter(entry => entry.origin === 'cloud').length } }
118
+ result.fingerprint = fingerprint(JSON.stringify({ record, roots, targets: unique, productsBefore }))
119
+ return result
120
+ }
121
+
122
+ publicPlan(plan) {
123
+ return { id: plan.id, code: plan.code, title: plan.title, fingerprint: plan.fingerprint, totals: plan.totals,
124
+ files: plan.targets.filter(target => !['receipt', 'candidate'].includes(target.kind)).map(target => ({
125
+ group: labels[target.area], path: target.relative, files: fileCount(target.snapshot) })) }
126
+ }
127
+
128
+ async preview(id) {
129
+ await this.ready
130
+ return this.locked(async () => this.publicPlan(await this.scan(id)))
131
+ }
132
+
133
+ async journalFile(requestId) {
134
+ if (!REQUEST.test(requestId || '')) throw new PmError('bad_input', '删除请求编号无效。')
135
+ return safePath(this.pm.root, CONTROL + '/' + requestId + '.json')
136
+ }
137
+
138
+ async save(transaction) {
139
+ await atomicWrite(await this.journalFile(transaction.requestId), JSON.stringify(transaction, null, 2))
140
+ }
141
+
142
+ async locations(transaction, move) {
143
+ const roots = this.roots()
144
+ if (JSON.stringify(transaction.roots) !== JSON.stringify(roots) || !Object.hasOwn(roots, move.area)) {
145
+ throw new PmError('conflict', '目录配置已变化,请恢复删除时的配置后完成清理。')
146
+ }
147
+ const from = await safePath(roots[move.area], move.relative)
148
+ const stagedRelative = path.join(path.dirname(move.relative), '.' + path.basename(move.relative) + '.delete-' + transaction.requestId)
149
+ const staged = await safePath(roots[move.area], stagedRelative)
150
+ return { from, staged }
151
+ }
152
+
153
+ async restore(transaction) {
154
+ for (const move of [...transaction.moves].reverse()) {
155
+ const { from, staged } = await this.locations(transaction, move)
156
+ if (!await fs.lstat(staged).catch(missing)) continue
157
+ if (await fs.lstat(from).catch(missing)) throw new PmError('conflict', '删除恢复遇到同名新文件,请先核对目录。')
158
+ await fs.rename(staged, from)
159
+ }
160
+ const productsFile = await safePath(this.pm.knowledgeDir, '.sa-knowledge-control/products.json')
161
+ const current = await readOptional(productsFile)
162
+ if (current !== transaction.productsBefore) {
163
+ if (current !== transaction.productsAfter || transaction.productsBefore === null) throw new PmError('conflict', '知识关联索引已变化,删除恢复停止。')
164
+ await atomicWrite(productsFile, transaction.productsBefore)
165
+ }
166
+ transaction.phase = 'rolled-back'
167
+ await this.save(transaction)
168
+ }
169
+
170
+ async finish(transaction) {
171
+ try {
172
+ for (const move of transaction.moves) {
173
+ const { staged } = await this.locations(transaction, move)
174
+ await fs.rm(staged, { recursive: true, force: true })
175
+ }
176
+ } catch {
177
+ throw new PmError('conflict', '需求已删除,文件清理未完成,请重试清理。')
178
+ }
179
+ transaction.phase = 'complete'
180
+ // Completed receipts retain identity and counts only, so retries are safe.
181
+ delete transaction.moves
182
+ delete transaction.productsBefore
183
+ delete transaction.productsAfter
184
+ await this.save(transaction)
185
+ return transaction.result
186
+ }
187
+
188
+ async recover() {
189
+ const directory = await safePath(this.pm.root, CONTROL)
190
+ const names = await fs.readdir(directory).catch(error => { if (error.code === 'ENOENT') return []; throw error })
191
+ if (!names.length) return
192
+ await this.locked(async () => {
193
+ for (const name of names.filter(name => REQUEST.test(name.replace(/\.json$/, '')))) {
194
+ const transaction = JSON.parse(await fs.readFile(await safePath(directory, name), 'utf8'))
195
+ if (!['prepared', 'committed'].includes(transaction.phase)) continue
196
+ const state = await loadState(this.pm.root)
197
+ if (transaction.phase === 'committed' || !state.requirements[transaction.id]) await this.finish(transaction)
198
+ else await this.restore(transaction)
199
+ }
200
+ })
201
+ }
202
+
203
+ async delete(id, input) {
204
+ await this.ready
205
+ if (input?.confirmed !== true || typeof input.expectedFingerprint !== 'string') throw new PmError('bad_input', '请先核对删除范围并确认删除。')
206
+ return this.locked(async () => {
207
+ const file = await this.journalFile(input.requestId)
208
+ const saved = await readOptional(file)
209
+ let transaction = saved && JSON.parse(saved)
210
+ if (transaction && (transaction.id !== id || transaction.fingerprint !== input.expectedFingerprint)) throw new PmError('conflict', '删除请求与原确认范围不一致。')
211
+ if (transaction?.phase === 'complete') return transaction.result
212
+ if (transaction?.phase === 'committed') return this.finish(transaction)
213
+ if (transaction?.phase === 'prepared') {
214
+ const state = await loadState(this.pm.root)
215
+ if (!state.requirements[id]) return this.finish(transaction)
216
+ await this.restore(transaction)
217
+ }
218
+ const plan = await this.scan(id)
219
+ if (plan.fingerprint !== input.expectedFingerprint) throw new PmError('conflict', '需求或关联资料已变化,请刷新删除清单后重新确认。')
220
+ transaction = { schemaVersion: 1, requestId: input.requestId, id, roots: plan.roots, fingerprint: plan.fingerprint, phase: 'prepared',
221
+ moves: plan.targets.map(({ area, relative }) => ({ area, relative })), productsBefore: plan.productsBefore, productsAfter: plan.productsAfter,
222
+ result: { deleted: true, id, code: plan.code, totals: plan.totals } }
223
+ await this.save(transaction)
224
+ try {
225
+ for (const move of transaction.moves) {
226
+ const { from, staged } = await this.locations(transaction, move)
227
+ if (await fs.lstat(staged).catch(missing)) throw new PmError('conflict', '存在待恢复的删除文件,请先完成恢复。')
228
+ await fs.rename(from, staged)
229
+ }
230
+ for (const target of plan.targets) {
231
+ const { staged, from } = await this.locations(transaction, target)
232
+ const targetRoot = plan.roots[target.area]
233
+ const stagedSnapshot = await tree(targetRoot, path.relative(targetRoot, staged))
234
+ if (fingerprint(JSON.stringify(stagedSnapshot)) !== fingerprint(JSON.stringify(target.snapshot)) || await fs.lstat(from).catch(missing)) {
235
+ throw new PmError('conflict', '关联文件在删除期间发生变化,请重新核对。')
236
+ }
237
+ }
238
+ if (plan.productsBefore !== plan.productsAfter) await atomicWrite(await safePath(this.pm.knowledgeDir, '.sa-knowledge-control/products.json'), plan.productsAfter)
239
+ const state = await loadState(this.pm.root)
240
+ delete state.requirements[id]
241
+ await saveState(this.pm.root, state)
242
+ } catch (error) {
243
+ const state = await loadState(this.pm.root)
244
+ if (!state.requirements[id]) return this.finish(transaction)
245
+ await this.restore(transaction)
246
+ throw error
247
+ }
248
+ transaction.phase = 'committed'
249
+ await this.save(transaction)
250
+ return this.finish(transaction)
251
+ })
252
+ }
253
+ }
254
+
255
+ export function installRequirementDeletion(pm) {
256
+ const deletion = new RequirementDeletion(pm)
257
+ deletion.ready.catch(error => console.error('[sa-deletion] 恢复未完成:', error.message))
258
+ pm.requirementDeletion = deletion
259
+ // Share one workspace write guard with existing state writers. Delivery and
260
+ // knowledge writers retain their own locks, also held by cascade deletion.
261
+ for (const name of ['createRequirement', 'updateRequirement', 'startRequirement', 'archive', 'beginDocumentArchive', 'recordDocumentArchive', 'saveArtifact', 'mutateAnnotation', 'addThread', 'updateThread', 'saveSource', 'saveSettings']) {
262
+ const original = pm[name]
263
+ if (original) pm[name] = async (...args) => { await deletion.ready; return withKnowledgeLock(pm.root, () => original(...args)) }
264
+ }
265
+ for (const name of ['loadState', 'findRequirement', 'viewAll', 'viewRequirement']) {
266
+ const original = pm[name]
267
+ if (original) pm[name] = async (...args) => { await deletion.ready; return original(...args) }
268
+ }
269
+ for (const [service, methods] of [[pm.deliveryLibrary, ['capture', 'saveCandidates']], [pm.knowledgeWorkflow, ['approve']], [pm.deliveryLibrary.workflow, ['approve']], [pm.productContext, ['save']]]) {
270
+ for (const name of methods) {
271
+ const original = service?.[name]
272
+ if (original) service[name] = async (...args) => { await deletion.ready; return original.apply(service, args) }
273
+ }
274
+ }
275
+ pm.deleteRequirement = (id, input) => deletion.delete(id, input)
276
+ }