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.
@@ -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: 'warning', detail: `缺少 ${missing.length} 份 scan 文档: ${missing.join(', ')}` })
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: 'failed',
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: 'failed',
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: 'failed',
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: 'warning',
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: 'warning',
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: 'warning', detail: ep.detail })
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: 'failed',
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: 'warning',
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 === 'failed')
187
- const hasWarning = checks.some(c => c.severity === 'warning')
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 = 'failed_post_check'
192
+ status = SCAN_STATUS.FAILED_POST_CHECK
192
193
  } else if (hasWarning) {
193
- status = 'completed_with_warnings'
194
+ status = SCAN_STATUS.COMPLETED_WITH_WARNINGS
194
195
  } else {
195
- status = 'success'
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
  }
@@ -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}/${doc}`)
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 planDir = join(cwd, '.sillyspec', 'changes', changeName)
83
- const planFile = join(planDir, 'plan.md')
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 完成校验:检查 verify 报告存在
301
+ * verify 完成校验:检查变更目录和 verify 产物
96
302
  */
97
- function validateVerifyOutputs(cwd, changeName) {
98
- const planDir = join(cwd, '.sillyspec', 'changes', changeName)
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
- // verify 至少应该有 run 记录
103
- if (!existsSync(join(planDir, 'plan.md'))) {
104
- errors.push(`变更目录缺失: ${planDir}`)
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',