sillyspec 3.17.15 → 3.18.1
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.
- package/docs/platform-scan-protocol.md +298 -0
- package/package.json +1 -1
- package/src/constants.js +70 -0
- package/src/db.js +4 -0
- package/src/hooks/worktree-guard.js +97 -4
- package/src/index.js +56 -1
- package/src/progress.js +41 -14
- package/src/run.js +315 -83
- package/src/scan-postcheck.js +17 -16
- package/src/stage-contract.js +244 -12
- package/src/stages/brainstorm.js +228 -8
- package/src/stages/index.js +0 -2
- package/src/stages/plan.js +237 -52
- package/src/stages/propose.js +30 -4
- package/src/stages/quick.js +13 -10
- package/src/stages/scan.js +12 -0
- package/src/stages/verify.js +31 -13
- package/src/workflow.js +1 -0
- package/test/platform-artifacts.test.mjs +190 -0
- package/test/platform-failure-samples.test.mjs +195 -0
- package/test/platform-recovery-chain.test.mjs +179 -0
- package/test/platform-recovery.test.mjs +14 -6
- package/test/platform-scan-p0.test.mjs +5 -2
- package/test/run-tests.mjs +31 -3
- package/test/scan-paths.test.mjs +1 -1
- package/test/scan-postcheck.test.mjs +4 -3
- package/test/spec-dir.test.mjs +3 -2
- package/test/stage-contract.test.mjs +120 -7
- package/test/stage-definitions.test.mjs +2 -6
- package/test/wait-gates.test.mjs +501 -0
- package/test/worktree-guard.test.mjs +58 -0
package/src/scan-postcheck.js
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { existsSync, readdirSync, readFileSync, mkdirSync, writeFileSync } from 'fs'
|
|
9
9
|
import { join, basename } from 'path'
|
|
10
|
+
import { SCAN_STATUS, CHECK_SEVERITY } from './constants.js'
|
|
10
11
|
|
|
11
12
|
const REQUIRED_SCAN_DOCS = [
|
|
12
13
|
'ARCHITECTURE.md',
|
|
@@ -41,7 +42,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
41
42
|
// 检查 7 份文档是否存在
|
|
42
43
|
const missing = REQUIRED_SCAN_DOCS.filter(f => !existsSync(join(scanDir, f)))
|
|
43
44
|
if (missing.length > 0) {
|
|
44
|
-
checks.push({ name: 'missing_docs', severity:
|
|
45
|
+
checks.push({ name: 'missing_docs', severity: CHECK_SEVERITY.WARNING, detail: `缺少 ${missing.length} 份 scan 文档: ${missing.join(', ')}` })
|
|
45
46
|
}
|
|
46
47
|
|
|
47
48
|
const hasWarning = checks.some(c => c.severity === 'warning')
|
|
@@ -62,8 +63,8 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
62
63
|
const leaked = readdirSync(localSub, { recursive: true }).filter(e => String(e).endsWith('.md') || String(e).endsWith('.yaml') || String(e).endsWith('.json'))
|
|
63
64
|
if (leaked.length > 0) {
|
|
64
65
|
checks.push({
|
|
65
|
-
name: 'source_root_leak',
|
|
66
|
-
severity:
|
|
66
|
+
name: sub === 'docs' ? 'source_root_docs_leak' : 'source_root_leak',
|
|
67
|
+
severity: CHECK_SEVERITY.FAILED,
|
|
67
68
|
detail: `source_root/.sillyspec/${sub}/ 下存在 ${leaked.length} 个文件(${localSub}/),agent 写入到了错误路径`
|
|
68
69
|
})
|
|
69
70
|
}
|
|
@@ -75,7 +76,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
75
76
|
if (existsSync(filePath)) {
|
|
76
77
|
checks.push({
|
|
77
78
|
name: 'source_root_leak',
|
|
78
|
-
severity:
|
|
79
|
+
severity: CHECK_SEVERITY.FAILED,
|
|
79
80
|
detail: `source_root/.sillyspec/${file} 存在,agent 写入到了错误路径(${filePath})`
|
|
80
81
|
})
|
|
81
82
|
}
|
|
@@ -87,7 +88,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
87
88
|
if (missingDocs.length > 0) {
|
|
88
89
|
checks.push({
|
|
89
90
|
name: missingDocs.length === REQUIRED_SCAN_DOCS.length ? 'all_docs_missing' : 'partial_docs_missing',
|
|
90
|
-
severity:
|
|
91
|
+
severity: CHECK_SEVERITY.FAILED,
|
|
91
92
|
detail: missingDocs.length === REQUIRED_SCAN_DOCS.length
|
|
92
93
|
? `spec_root 下无任何 scan 文档(${specScanDir}/),扫描可能未执行`
|
|
93
94
|
: `spec_root 缺少必需文档: ${missingDocs.join(', ')}(7 份 scan 文档均为 required)`
|
|
@@ -106,7 +107,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
106
107
|
if (docsMissingHeader.length > 0) {
|
|
107
108
|
checks.push({
|
|
108
109
|
name: 'docs_missing_header',
|
|
109
|
-
severity:
|
|
110
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
110
111
|
detail: `${docsMissingHeader.length} 份文档缺少 author/created_at: ${docsMissingHeader.join(', ')}`
|
|
111
112
|
})
|
|
112
113
|
}
|
|
@@ -143,7 +144,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
143
144
|
if (invalidCommands.length > 0) {
|
|
144
145
|
checks.push({
|
|
145
146
|
name: 'local_config_invalid',
|
|
146
|
-
severity:
|
|
147
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
147
148
|
detail: `local.yaml 引用不存在的命令: ${invalidCommands.join('; ')}`
|
|
148
149
|
})
|
|
149
150
|
}
|
|
@@ -159,7 +160,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
159
160
|
]
|
|
160
161
|
for (const ep of errorPatterns) {
|
|
161
162
|
if (ep.pattern.test(outputText)) {
|
|
162
|
-
checks.push({ name: ep.name, severity:
|
|
163
|
+
checks.push({ name: ep.name, severity: CHECK_SEVERITY.WARNING, detail: ep.detail })
|
|
163
164
|
}
|
|
164
165
|
}
|
|
165
166
|
}
|
|
@@ -168,7 +169,7 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
168
169
|
if (scanMeta.manifestWritten === false) {
|
|
169
170
|
checks.push({
|
|
170
171
|
name: 'manifest_write_failed',
|
|
171
|
-
severity:
|
|
172
|
+
severity: CHECK_SEVERITY.FAILED,
|
|
172
173
|
detail: 'manifest.json 写入失败,平台无法消费 scan 结果'
|
|
173
174
|
})
|
|
174
175
|
}
|
|
@@ -177,22 +178,22 @@ export function runScanPostCheck({ cwd, specDir, outputText = '', scanMeta = {}
|
|
|
177
178
|
if (scanMeta.projectListParsed === false) {
|
|
178
179
|
checks.push({
|
|
179
180
|
name: 'project_list_parse_failed',
|
|
180
|
-
severity:
|
|
181
|
+
severity: CHECK_SEVERITY.WARNING,
|
|
181
182
|
detail: 'Step 2 项目列表解析失败,回退到注册项目列表,可能遗漏子项目'
|
|
182
183
|
})
|
|
183
184
|
}
|
|
184
185
|
|
|
185
186
|
// 8. 计算 finalStatus
|
|
186
|
-
const hasFailed = checks.some(c => c.severity ===
|
|
187
|
-
const hasWarning = checks.some(c => c.severity ===
|
|
187
|
+
const hasFailed = checks.some(c => c.severity === CHECK_SEVERITY.FAILED)
|
|
188
|
+
const hasWarning = checks.some(c => c.severity === CHECK_SEVERITY.WARNING)
|
|
188
189
|
|
|
189
190
|
let status
|
|
190
191
|
if (hasFailed) {
|
|
191
|
-
status =
|
|
192
|
+
status = SCAN_STATUS.FAILED_POST_CHECK
|
|
192
193
|
} else if (hasWarning) {
|
|
193
|
-
status =
|
|
194
|
+
status = SCAN_STATUS.COMPLETED_WITH_WARNINGS
|
|
194
195
|
} else {
|
|
195
|
-
status =
|
|
196
|
+
status = SCAN_STATUS.SUCCESS
|
|
196
197
|
}
|
|
197
198
|
|
|
198
199
|
return { status, checks }
|
|
@@ -254,7 +255,7 @@ export function formatStructuredResult(result, meta = {}) {
|
|
|
254
255
|
const entry = { name: check.name, detail: check.detail, severity }
|
|
255
256
|
|
|
256
257
|
// 路径污染类
|
|
257
|
-
if (check.name === 'source_root_leak') {
|
|
258
|
+
if (check.name === 'source_root_leak' || check.name === 'source_root_docs_leak') {
|
|
258
259
|
structured.failure_categories.path_pollution.push(entry)
|
|
259
260
|
structured.failure_categories.violations.push(entry)
|
|
260
261
|
}
|
package/src/stage-contract.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* CLI 不再相信 prompt 完成,completeStep 后必须过 validator。
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import { existsSync, readdirSync } from 'fs'
|
|
8
|
+
import { existsSync, readdirSync, readFileSync } from 'fs'
|
|
9
9
|
import { join, basename } from 'path'
|
|
10
10
|
|
|
11
11
|
/**
|
|
@@ -26,6 +26,117 @@ import { join, basename } from 'path'
|
|
|
26
26
|
|
|
27
27
|
// ============ Validators ============
|
|
28
28
|
|
|
29
|
+
function resolveChangeDir(cwd, changeName, specRoot = null) {
|
|
30
|
+
const changesRoot = specRoot ? join(specRoot, 'changes') : join(cwd, '.sillyspec', 'changes')
|
|
31
|
+
return join(changesRoot, changeName)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function collectIdsFromLine(line, re, ids) {
|
|
35
|
+
for (const match of line.matchAll(re)) {
|
|
36
|
+
ids.add(match[0].toUpperCase())
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function extractIds(content, prefix) {
|
|
41
|
+
if (!content) return []
|
|
42
|
+
const ids = new Set()
|
|
43
|
+
const idRe = new RegExp(`\\b${prefix}-\\d+(?:@v\\d+)?\\b`, 'gi')
|
|
44
|
+
const headingLineRe = /^\s{0,3}#{1,6}\s+/i
|
|
45
|
+
const fieldLineRe = /^\s*(?:[-*]\s*)?(?:id|decision[-_ ]?ids?|requirement[-_ ]?ids?|covers?|coverage|references?|impacts?|覆盖(?:来源|决策|需求)?)\s*[::]/i
|
|
46
|
+
const tableLineRe = /^\s*\|/
|
|
47
|
+
const listStartsWithIdRe = new RegExp(`^\\s*(?:[-*]|\\d+\\.)\\s*(?:\\[[ xX]\\]\\s*)?${prefix}-\\d+(?:@v\\d+)?\\b`, 'i')
|
|
48
|
+
|
|
49
|
+
for (const line of content.split(/\r?\n/)) {
|
|
50
|
+
if (!headingLineRe.test(line) && !fieldLineRe.test(line) && !tableLineRe.test(line) && !listStartsWithIdRe.test(line)) continue
|
|
51
|
+
collectIdsFromLine(line, idRe, ids)
|
|
52
|
+
}
|
|
53
|
+
return [...ids].sort()
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function readDecisionField(body, fieldPattern, fallback = '') {
|
|
57
|
+
const re = new RegExp(`^\\s*(?:[-*]\\s*)?(?:${fieldPattern})\\s*[::]\\s*([^\\n]+)`, 'im')
|
|
58
|
+
return (body.match(re)?.[1] || fallback).trim()
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function buildDecisionRecord(id, body) {
|
|
62
|
+
const status = readDecisionField(body, 'status', 'accepted').toLowerCase()
|
|
63
|
+
const blockerValue = readDecisionField(body, 'blocker', 'false').toLowerCase()
|
|
64
|
+
const blocker = ['true', 'yes', '1'].includes(blockerValue)
|
|
65
|
+
const priorityValue = readDecisionField(body, 'priority|level|severity')
|
|
66
|
+
const priorityMissing = priorityValue.length === 0
|
|
67
|
+
const fallbackPriority = (['unresolved', 'blocking'].includes(status) || blocker) ? 'P1' : 'P2'
|
|
68
|
+
const priority = (priorityValue.match(/P[0-2]/i)?.[0] || fallbackPriority).toUpperCase()
|
|
69
|
+
return { id: id.toUpperCase(), body, status, priority, blocker, priorityMissing }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function findNextDecisionBoundary(content, startIndex) {
|
|
73
|
+
const boundaryRe = /^(\s{0,3}#{2,6}\s+D-\d+(?:@v\d+)?\b|\s*(?:[-*]\s*)?(?:id|decision[-_ ]?id|decision)\s*[::]\s*D-\d+(?:@v\d+)?\b)/gmi
|
|
74
|
+
boundaryRe.lastIndex = startIndex
|
|
75
|
+
const next = boundaryRe.exec(content)
|
|
76
|
+
return next ? next.index : content.length
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isInsideRange(index, ranges) {
|
|
80
|
+
return ranges.some(range => index >= range.start && index < range.end)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function parseDecisionRecords(content) {
|
|
84
|
+
if (!content) return []
|
|
85
|
+
const records = []
|
|
86
|
+
const ranges = []
|
|
87
|
+
const headingRe = /^\s{0,3}#{2,6}\s+(D-\d+(?:@v\d+)?)(?:\b|:)[^\n]*$/gmi
|
|
88
|
+
const headings = []
|
|
89
|
+
let match
|
|
90
|
+
while ((match = headingRe.exec(content)) !== null) {
|
|
91
|
+
headings.push({ id: match[1].toUpperCase(), index: match.index, end: headingRe.lastIndex })
|
|
92
|
+
}
|
|
93
|
+
for (let i = 0; i < headings.length; i++) {
|
|
94
|
+
const current = headings[i]
|
|
95
|
+
const next = headings[i + 1]
|
|
96
|
+
const body = content.slice(current.end, next ? next.index : content.length)
|
|
97
|
+
const end = next ? next.index : content.length
|
|
98
|
+
ranges.push({ start: current.index, end })
|
|
99
|
+
records.push(buildDecisionRecord(current.id, body))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const idLineRe = /^\s*(?:[-*]\s*)?(?:id|decision[-_ ]?id|decision)\s*[::]\s*(D-\d+(?:@v\d+)?)(?:\b|$)/gmi
|
|
103
|
+
while ((match = idLineRe.exec(content)) !== null) {
|
|
104
|
+
if (isInsideRange(match.index, ranges)) continue
|
|
105
|
+
const bodyEnd = findNextDecisionBoundary(content, idLineRe.lastIndex)
|
|
106
|
+
const body = content.slice(match.index, bodyEnd)
|
|
107
|
+
records.push(buildDecisionRecord(match[1], body))
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return records
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function extractCurrentDecisionIds(content) {
|
|
114
|
+
const records = parseDecisionRecords(content)
|
|
115
|
+
if (records.length === 0) return extractIds(content, 'D')
|
|
116
|
+
return records
|
|
117
|
+
.filter(r => !['superseded', 'rejected'].includes(r.status))
|
|
118
|
+
.map(r => r.id)
|
|
119
|
+
.sort()
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function findBlockingDecisionIssues(content) {
|
|
123
|
+
return parseDecisionRecords(content)
|
|
124
|
+
.filter(r => (r.blocker || ['unresolved', 'blocking'].includes(r.status)) && ['P0', 'P1'].includes(r.priority))
|
|
125
|
+
.map(r => `${r.id} (${r.priority}${r.priorityMissing ? ', priority=missing->P1' : ''}, status=${r.status})`)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readIfExists(file) {
|
|
129
|
+
return existsSync(file) ? readFileSync(file, 'utf8') : ''
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function warnMissingIds(warnings, ids, targetContent, targetName, sourceName) {
|
|
133
|
+
for (const id of ids) {
|
|
134
|
+
if (!targetContent.toUpperCase().includes(id)) {
|
|
135
|
+
warnings.push(`${targetName} 未引用 ${sourceName} 中的 ${id}`)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
29
140
|
/**
|
|
30
141
|
* scan 完成校验:检查 7 份 scan 文档 + manifest
|
|
31
142
|
*/
|
|
@@ -55,7 +166,7 @@ function validateScanOutputs(cwd, changeName, context = {}) {
|
|
|
55
166
|
|
|
56
167
|
for (const doc of requiredDocs) {
|
|
57
168
|
if (!existsSync(join(docsRoot, doc))) {
|
|
58
|
-
errors.push(`scan 文档缺失: ${docsRoot
|
|
169
|
+
errors.push(`scan 文档缺失: ${join(docsRoot, doc)}`)
|
|
59
170
|
}
|
|
60
171
|
}
|
|
61
172
|
|
|
@@ -75,12 +186,93 @@ function validateScanOutputs(cwd, changeName, context = {}) {
|
|
|
75
186
|
return { ok: errors.length === 0, errors, warnings }
|
|
76
187
|
}
|
|
77
188
|
|
|
189
|
+
/**
|
|
190
|
+
* brainstorm 完成校验:检查四件套规范文件是否生成
|
|
191
|
+
*/
|
|
192
|
+
function validateBrainstormOutputs(cwd, changeName, context = {}) {
|
|
193
|
+
const { specRoot } = context
|
|
194
|
+
const changesRoot = specRoot ? join(specRoot, 'changes') : join(cwd, '.sillyspec', 'changes')
|
|
195
|
+
if (specRoot && !existsSync(changesRoot)) {
|
|
196
|
+
return { ok: false, errors: [`平台模式 specRoot 缺少 changes 目录: ${changesRoot}`], warnings: [] }
|
|
197
|
+
}
|
|
198
|
+
const changeDir = resolveChangeDir(cwd, changeName, specRoot)
|
|
199
|
+
const errors = []
|
|
200
|
+
const warnings = []
|
|
201
|
+
|
|
202
|
+
const requiredFiles = ['design.md', 'proposal.md', 'requirements.md', 'tasks.md']
|
|
203
|
+
|
|
204
|
+
for (const file of requiredFiles) {
|
|
205
|
+
if (!existsSync(join(changeDir, file))) {
|
|
206
|
+
errors.push(`brainstorm 产物缺失: ${join(changeDir, file)}`)
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// 内容校验(文件存在时检查关键章节)
|
|
211
|
+
if (existsSync(join(changeDir, 'proposal.md'))) {
|
|
212
|
+
const content = readFileSync(join(changeDir, 'proposal.md'), 'utf8')
|
|
213
|
+
if (!content.includes('不在范围内') && !content.includes('Non-Goals') && !content.includes('非目标')) {
|
|
214
|
+
warnings.push('proposal.md 缺少「不在范围内/Non-Goals」章节')
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (existsSync(join(changeDir, 'requirements.md'))) {
|
|
219
|
+
const content = readFileSync(join(changeDir, 'requirements.md'), 'utf8')
|
|
220
|
+
if (!/FR-\d+/i.test(content)) {
|
|
221
|
+
warnings.push('requirements.md 缺少 FR 编号的需求项')
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
if (existsSync(join(changeDir, 'design.md'))) {
|
|
226
|
+
const content = readFileSync(join(changeDir, 'design.md'), 'utf8')
|
|
227
|
+
if (!content.includes('文件变更清单') && !content.includes('File Changes') && !content.includes('文件清单')) {
|
|
228
|
+
warnings.push('design.md 缺少「文件变更清单」章节')
|
|
229
|
+
}
|
|
230
|
+
if (!content.includes('风险登记') && !content.includes('Risk') && !content.includes('风险')) {
|
|
231
|
+
warnings.push('design.md 缺少「风险登记」章节')
|
|
232
|
+
}
|
|
233
|
+
if (!content.includes('自审') && !content.includes('Self-Review') && !content.includes('Self-review')) {
|
|
234
|
+
warnings.push('design.md 缺少「自审」章节')
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (existsSync(join(changeDir, 'tasks.md'))) {
|
|
239
|
+
const content = readFileSync(join(changeDir, 'tasks.md'), 'utf8')
|
|
240
|
+
const lines = content.split('\n').filter(l => l.trim().startsWith('-') || l.trim().startsWith('*') || /^\d+\./.test(l.trim()))
|
|
241
|
+
if (lines.length === 0) {
|
|
242
|
+
warnings.push('tasks.md 没有任务列表项')
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const decisionsFile = join(changeDir, 'decisions.md')
|
|
247
|
+
if (existsSync(decisionsFile)) {
|
|
248
|
+
const decisions = readFileSync(decisionsFile, 'utf8')
|
|
249
|
+
const blockers = findBlockingDecisionIssues(decisions)
|
|
250
|
+
for (const issue of blockers) {
|
|
251
|
+
errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
|
|
252
|
+
}
|
|
253
|
+
const decisionIds = extractCurrentDecisionIds(decisions)
|
|
254
|
+
if (decisionIds.length === 0) {
|
|
255
|
+
warnings.push('decisions.md 存在但没有当前版本 D-xxx@vN 决策 ID')
|
|
256
|
+
} else {
|
|
257
|
+
const design = readIfExists(join(changeDir, 'design.md'))
|
|
258
|
+
const requirements = readIfExists(join(changeDir, 'requirements.md'))
|
|
259
|
+
const tasks = readIfExists(join(changeDir, 'tasks.md'))
|
|
260
|
+
warnMissingIds(warnings, decisionIds, design, 'design.md', 'decisions.md')
|
|
261
|
+
warnMissingIds(warnings, decisionIds, requirements, 'requirements.md', 'decisions.md')
|
|
262
|
+
warnMissingIds(warnings, decisionIds, tasks, 'tasks.md', 'decisions.md')
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
return { ok: errors.length === 0, errors, warnings }
|
|
267
|
+
}
|
|
268
|
+
|
|
78
269
|
/**
|
|
79
270
|
* plan 完成校验:检查 plan.md 生成
|
|
80
271
|
*/
|
|
81
|
-
function validatePlanOutputs(cwd, changeName) {
|
|
82
|
-
const
|
|
83
|
-
const
|
|
272
|
+
function validatePlanOutputs(cwd, changeName, context = {}) {
|
|
273
|
+
const { specRoot } = context
|
|
274
|
+
const changeDir = resolveChangeDir(cwd, changeName, specRoot)
|
|
275
|
+
const planFile = join(changeDir, 'plan.md')
|
|
84
276
|
const errors = []
|
|
85
277
|
|
|
86
278
|
if (!existsSync(planFile)) {
|
|
@@ -88,20 +280,60 @@ function validatePlanOutputs(cwd, changeName) {
|
|
|
88
280
|
}
|
|
89
281
|
|
|
90
282
|
const warnings = []
|
|
283
|
+
if (existsSync(planFile)) {
|
|
284
|
+
const plan = readFileSync(planFile, 'utf8')
|
|
285
|
+
const requirements = readIfExists(join(changeDir, 'requirements.md'))
|
|
286
|
+
const requirementIds = extractIds(requirements, 'FR')
|
|
287
|
+
warnMissingIds(warnings, requirementIds, plan, 'plan.md', 'requirements.md')
|
|
288
|
+
|
|
289
|
+
const decisions = readIfExists(join(changeDir, 'decisions.md'))
|
|
290
|
+
const blockers = findBlockingDecisionIssues(decisions)
|
|
291
|
+
for (const issue of blockers) {
|
|
292
|
+
errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
|
|
293
|
+
}
|
|
294
|
+
const decisionIds = extractCurrentDecisionIds(decisions)
|
|
295
|
+
warnMissingIds(warnings, decisionIds, plan, 'plan.md', 'decisions.md')
|
|
296
|
+
}
|
|
91
297
|
return { ok: errors.length === 0, errors, warnings }
|
|
92
298
|
}
|
|
93
299
|
|
|
94
300
|
/**
|
|
95
|
-
* verify
|
|
301
|
+
* verify 完成校验:检查变更目录和 verify 产物
|
|
96
302
|
*/
|
|
97
|
-
function validateVerifyOutputs(cwd, changeName) {
|
|
98
|
-
const
|
|
303
|
+
function validateVerifyOutputs(cwd, changeName, context = {}) {
|
|
304
|
+
const { specRoot } = context
|
|
305
|
+
const changeDir = resolveChangeDir(cwd, changeName, specRoot)
|
|
99
306
|
const errors = []
|
|
100
307
|
const warnings = []
|
|
101
308
|
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
errors
|
|
309
|
+
if (!existsSync(changeDir)) {
|
|
310
|
+
errors.push(`变更目录缺失: ${changeDir}`)
|
|
311
|
+
return { ok: false, errors, warnings }
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// verify 阶段应该产出 verify-result.md(或类似报告)
|
|
315
|
+
const verifyResult = join(changeDir, 'verify-result.md')
|
|
316
|
+
if (!existsSync(verifyResult)) {
|
|
317
|
+
warnings.push('verify-result.md 不存在(verify 阶段建议产出验证报告)')
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 确保核心规范文件仍然存在
|
|
321
|
+
const requiredDocs = ['design.md', 'plan.md']
|
|
322
|
+
for (const doc of requiredDocs) {
|
|
323
|
+
if (!existsSync(join(changeDir, doc))) {
|
|
324
|
+
errors.push(`核心文档缺失: ${join(changeDir, doc)}`)
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
if (existsSync(verifyResult)) {
|
|
329
|
+
const verify = readFileSync(verifyResult, 'utf8')
|
|
330
|
+
const decisions = readIfExists(join(changeDir, 'decisions.md'))
|
|
331
|
+
const blockers = findBlockingDecisionIssues(decisions)
|
|
332
|
+
for (const issue of blockers) {
|
|
333
|
+
errors.push(`decisions.md 存在 P0/P1 未决阻塞: ${issue}`)
|
|
334
|
+
}
|
|
335
|
+
const decisionIds = extractCurrentDecisionIds(decisions)
|
|
336
|
+
warnMissingIds(warnings, decisionIds, verify, 'verify-result.md', 'decisions.md')
|
|
105
337
|
}
|
|
106
338
|
|
|
107
339
|
return { ok: errors.length === 0, errors, warnings }
|
|
@@ -187,7 +419,7 @@ const contracts = {
|
|
|
187
419
|
description: '需求分析与设计',
|
|
188
420
|
allowedFrom: [], // 任何变更的起始阶段
|
|
189
421
|
allowedTo: ['plan'],
|
|
190
|
-
validators: [],
|
|
422
|
+
validators: [validateBrainstormOutputs],
|
|
191
423
|
},
|
|
192
424
|
plan: {
|
|
193
425
|
stage: 'plan',
|