sillyspec 3.18.5 → 3.19.0
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/package.json +1 -1
- package/src/index.js +15 -0
- package/src/run.js +137 -12
- package/src/stage-contract.js +14 -1
- package/src/stages/execute.js +31 -2
- package/src/stages/verify.js +8 -0
- package/src/task-review.js +346 -0
- package/src/workflow.js +12 -8
- package/templates/workflows/scan-docs.yaml +8 -8
- package/test/cli-top-level-aliases.test.mjs +174 -0
- package/test/run-sanitize-project-name.test.mjs +51 -0
- package/test/run-scan-postcheck-fail.test.mjs +64 -0
- package/test/run-scan-project-parse.test.mjs +77 -0
- package/test/scan-docs-yaml-placeholders.test.mjs +84 -0
- package/test/scan-postcheck-project-priority.test.mjs +85 -0
- package/test/scan-workflow-anyfailed-block.test.mjs +52 -0
- package/test/stage-contract-failed-post-check.test.mjs +102 -0
- package/test/workflow-spec-base.test.mjs +142 -0
|
@@ -0,0 +1,346 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SillySpec Task Review Gate — execute 阶段任务级评审校验
|
|
3
|
+
*
|
|
4
|
+
* execute 阶段每个 task 完成后,controller 必须写入 review.json。
|
|
5
|
+
* execute --done 时 CLI 硬校验:缺失 review 或 verdict 不通过则阻断。
|
|
6
|
+
*
|
|
7
|
+
* 目录结构:
|
|
8
|
+
* .sillyspec/.runtime/execute-runs/<runId>/tasks/<taskId>/review.json
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
|
|
12
|
+
import { join, resolve } from 'path'
|
|
13
|
+
|
|
14
|
+
// ── review.json schema version ──
|
|
15
|
+
export const REVIEW_SCHEMA_VERSION = 1
|
|
16
|
+
|
|
17
|
+
// ── 合法 verdict 枚举 ──
|
|
18
|
+
export const VALID_VERDICTS = ['pass', 'fail', 'cannot_verify']
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* 解析 plan.md 中的 task 列表
|
|
22
|
+
* @param {string} planContent - plan.md 文件内容
|
|
23
|
+
* @returns {string[]} task id 列表,如 ['task-01', 'task-02']
|
|
24
|
+
*/
|
|
25
|
+
export function parseTaskIdsFromPlan(planContent) {
|
|
26
|
+
if (!planContent) return []
|
|
27
|
+
const ids = new Set()
|
|
28
|
+
const re = /^\s*[-*]\s*\[[ x]\]\s*task-(\d+)/gim
|
|
29
|
+
for (const m of planContent.matchAll(re)) {
|
|
30
|
+
ids.add(`task-${m[1].padStart(2, '0')}`)
|
|
31
|
+
}
|
|
32
|
+
return [...ids].sort()
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* 校验单个 review.json 文件
|
|
37
|
+
* @param {object} review - 解析后的 JSON 对象
|
|
38
|
+
* @returns {{ ok: boolean, errors: string[] }}
|
|
39
|
+
*/
|
|
40
|
+
export function validateReviewSchema(review) {
|
|
41
|
+
const errors = []
|
|
42
|
+
if (!review || typeof review !== 'object') {
|
|
43
|
+
errors.push('review.json 不是有效 JSON 对象')
|
|
44
|
+
return { ok: false, errors }
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (review.schemaVersion !== REVIEW_SCHEMA_VERSION) {
|
|
48
|
+
errors.push(`schemaVersion 应为 ${REVIEW_SCHEMA_VERSION},实际为 ${review.schemaVersion}`)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!review.task || typeof review.task !== 'string') {
|
|
52
|
+
errors.push('缺少 task 字段(应为 "task-XX" 格式)')
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!VALID_VERDICTS.includes(review.specVerdict)) {
|
|
56
|
+
errors.push(`specVerdict 无效:${review.specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (!VALID_VERDICTS.includes(review.qualityVerdict)) {
|
|
60
|
+
errors.push(`qualityVerdict 无效:${review.qualityVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// cannot_verify 必须提供 requiredEvidence
|
|
64
|
+
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
65
|
+
if (!Array.isArray(review.requiredEvidence) || review.requiredEvidence.length === 0) {
|
|
66
|
+
errors.push('cannot_verify 的 verdict 必须提供非空的 requiredEvidence 数组')
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// base/head 非空检查
|
|
71
|
+
if (!review.base || typeof review.base !== 'string') {
|
|
72
|
+
errors.push('缺少 base 字段(git commit hash)')
|
|
73
|
+
}
|
|
74
|
+
if (!review.head || typeof review.head !== 'string') {
|
|
75
|
+
errors.push('缺少 head 字段(git commit hash)')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return { ok: errors.length === 0, errors }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* 读取单个 task 的 review.json
|
|
83
|
+
* @param {string} reviewPath - review.json 文件路径
|
|
84
|
+
* @returns {{ ok: boolean, review: object|null, errors: string[] }}
|
|
85
|
+
*/
|
|
86
|
+
export function readReview(reviewPath) {
|
|
87
|
+
if (!existsSync(reviewPath)) {
|
|
88
|
+
return { ok: false, review: null, errors: ['review.json 不存在'] }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
let raw
|
|
92
|
+
try {
|
|
93
|
+
raw = readFileSync(reviewPath, 'utf8')
|
|
94
|
+
} catch (e) {
|
|
95
|
+
return { ok: false, review: null, parseError: true, errors: [`review.json 读取失败: ${e.message}`] }
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let parsed
|
|
99
|
+
try {
|
|
100
|
+
parsed = JSON.parse(raw)
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// 文件存在但 JSON 非法:review 设为 null 但标记 parseError=true
|
|
103
|
+
return { ok: false, review: null, parseError: true, errors: [`review.json 解析失败: ${e.message}`] }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const schemaResult = validateReviewSchema(parsed)
|
|
107
|
+
if (!schemaResult.ok) {
|
|
108
|
+
return { ok: false, review: parsed, schemaError: true, errors: schemaResult.errors }
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
return { ok: true, review: parsed, errors: [] }
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* execute --done 时的 task review 总校验
|
|
116
|
+
*
|
|
117
|
+
* 规则:
|
|
118
|
+
* - 每个 plan task 必须有 review.json
|
|
119
|
+
* - specVerdict 或 qualityVerdict 为 fail → 整体 fail
|
|
120
|
+
* - specVerdict 或 qualityVerdict 为 cannot_verify → warning(requiredEvidence 非空)
|
|
121
|
+
* - cannot_verify + requiredEvidence 为空 → fail(agent 逃避判断)
|
|
122
|
+
* - cannot_verify 的 requiredEvidence 汇总到 requiredEvidence 字段,供 verify 阶段消费
|
|
123
|
+
*
|
|
124
|
+
* @param {object} opts
|
|
125
|
+
* @param {string} opts.planContent - plan.md 内容
|
|
126
|
+
* @param {string} opts.runtimeRoot - .sillyspec/.runtime 的绝对路径
|
|
127
|
+
* @param {string} opts.executeRunId - execute run id(如 'exec-2026-06-23-131400')
|
|
128
|
+
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
129
|
+
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
130
|
+
*/
|
|
131
|
+
export function validateTaskReviews(opts) {
|
|
132
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true } = opts
|
|
133
|
+
|
|
134
|
+
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
135
|
+
|
|
136
|
+
// 如果 plan 里没有 task,跳过校验(向后兼容)
|
|
137
|
+
if (taskIds.length === 0) {
|
|
138
|
+
return { ok: true, errors: [], warnings: [], requiredEvidence: [] }
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const errors = []
|
|
142
|
+
const warnings = []
|
|
143
|
+
const requiredEvidence = []
|
|
144
|
+
|
|
145
|
+
for (const taskId of taskIds) {
|
|
146
|
+
const reviewDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
147
|
+
const reviewPath = join(reviewDir, 'review.json')
|
|
148
|
+
|
|
149
|
+
const result = readReview(reviewPath)
|
|
150
|
+
|
|
151
|
+
if (!result.ok) {
|
|
152
|
+
if (result.parseError) {
|
|
153
|
+
// review.json 存在但 JSON 非法
|
|
154
|
+
errors.push(`${taskId}: review.json 解析失败 — ${result.errors.join('; ')}`)
|
|
155
|
+
} else if (result.schemaError) {
|
|
156
|
+
// review.json 存在且 JSON 合法,但 schema 校验失败
|
|
157
|
+
errors.push(`${taskId}: review.json 校验失败 — ${result.errors.join('; ')}`)
|
|
158
|
+
} else {
|
|
159
|
+
// review.json 不存在
|
|
160
|
+
errors.push(`${taskId}: 缺少 review.json — task 未经过评审`)
|
|
161
|
+
}
|
|
162
|
+
continue
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const review = result.review
|
|
166
|
+
|
|
167
|
+
// 检查 review.task 是否与 plan 中的 taskId 一致
|
|
168
|
+
if (review.task && review.task !== taskId) {
|
|
169
|
+
errors.push(`${taskId}: review.json 中的 task 字段为 "${review.task}",与 plan 不一致(应为 "${taskId}")— agent 可能复制模板未修改`)
|
|
170
|
+
continue
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// 检查 fail verdict
|
|
174
|
+
if (review.specVerdict === 'fail' || review.qualityVerdict === 'fail') {
|
|
175
|
+
errors.push(`${taskId}: review 未通过 — spec: ${review.specVerdict}, quality: ${review.qualityVerdict}`)
|
|
176
|
+
if (review.reviewerNotes) {
|
|
177
|
+
errors.push(`${taskId}: ${review.reviewerNotes}`)
|
|
178
|
+
}
|
|
179
|
+
continue
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// 检查 cannot_verify
|
|
183
|
+
if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
|
|
184
|
+
if (!allowCannotVerify) {
|
|
185
|
+
errors.push(`${taskId}: cannot_verify 不被允许 — 必须提供评审结果`)
|
|
186
|
+
continue
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (review.requiredEvidence && review.requiredEvidence.length > 0) {
|
|
190
|
+
const verdicts = []
|
|
191
|
+
if (review.specVerdict === 'cannot_verify') verdicts.push('spec')
|
|
192
|
+
if (review.qualityVerdict === 'cannot_verify') verdicts.push('quality')
|
|
193
|
+
warnings.push(`${taskId}: ${verdicts.join('+')}=cannot_verify,requiredEvidence 必须在 verify 阶段满足`)
|
|
194
|
+
requiredEvidence.push({
|
|
195
|
+
task: taskId,
|
|
196
|
+
verdict: verdicts.join('+'),
|
|
197
|
+
evidence: review.requiredEvidence,
|
|
198
|
+
})
|
|
199
|
+
} else {
|
|
200
|
+
// cannot_verify + 空 requiredEvidence = agent 逃避判断
|
|
201
|
+
errors.push(`${taskId}: cannot_verify 但 requiredEvidence 为空 — 这是无效评审`)
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// 额外检查:扫描 execute-runs/<runId>/tasks/ 下是否有 plan 里没有的 task review
|
|
207
|
+
// (agent 可能写错了 task id)
|
|
208
|
+
try {
|
|
209
|
+
const tasksDir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks')
|
|
210
|
+
if (existsSync(tasksDir)) {
|
|
211
|
+
const taskDirs = readdirSync(tasksDir, { withFileTypes: true })
|
|
212
|
+
.filter(e => e.isDirectory())
|
|
213
|
+
.map(e => e.name)
|
|
214
|
+
const taskIdSet = new Set(taskIds)
|
|
215
|
+
for (const dirName of taskDirs) {
|
|
216
|
+
if (!taskIdSet.has(dirName) && existsSync(join(tasksDir, dirName, 'review.json'))) {
|
|
217
|
+
warnings.push(`${dirName}: 存在 review.json 但不在 plan.md 的 task 列表中(可能是多余文件)`)
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
} catch (e) {
|
|
222
|
+
warnings.push(`task review extra-check 异常: ${e.message}`)
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
return {
|
|
226
|
+
ok: errors.length === 0,
|
|
227
|
+
errors,
|
|
228
|
+
warnings,
|
|
229
|
+
requiredEvidence,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 将 cannot_verify 的 requiredEvidence 写入 change 目录
|
|
235
|
+
* 供 verify 阶段消费
|
|
236
|
+
*
|
|
237
|
+
* @param {string} changeDir - 变更目录(.sillyspec/changes/<name>)
|
|
238
|
+
* @param {Array<{task: string, verdict: string, evidence: string[]}>} requiredEvidence
|
|
239
|
+
* @returns {string|null} 写入的文件路径,null 表示无需写入
|
|
240
|
+
*/
|
|
241
|
+
export function writeVerifyRequiredEvidence(changeDir, requiredEvidence) {
|
|
242
|
+
if (!requiredEvidence || requiredEvidence.length === 0) return null
|
|
243
|
+
|
|
244
|
+
const filePath = join(changeDir, 'verify-required-evidence.json')
|
|
245
|
+
const data = {
|
|
246
|
+
generatedAt: new Date().toISOString(),
|
|
247
|
+
schemaVersion: 1,
|
|
248
|
+
items: requiredEvidence,
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
mkdirSync(changeDir, { recursive: true })
|
|
252
|
+
writeFileSync(filePath, JSON.stringify(data, null, 2) + '\n')
|
|
253
|
+
|
|
254
|
+
return filePath
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* 生成 execute run id
|
|
259
|
+
* @returns {string} 如 'exec-2026-06-23-131400'
|
|
260
|
+
*/
|
|
261
|
+
export function generateExecuteRunId() {
|
|
262
|
+
const now = new Date()
|
|
263
|
+
const pad = (n) => String(n).padStart(2, '0')
|
|
264
|
+
return `exec-${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}${pad(now.getSeconds())}`
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* 获取当前(或最新)execute run id
|
|
269
|
+
* 从 runtime 目录下查找 execute-runs/ 子目录
|
|
270
|
+
*
|
|
271
|
+
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
272
|
+
* @returns {string|null} 最新 run id,null 表示无任何 run
|
|
273
|
+
*/
|
|
274
|
+
/**
|
|
275
|
+
* 获取当前 execute run id
|
|
276
|
+
* 优先从 current-execute-run-id 标记文件读取(execute 阶段启动时写入),
|
|
277
|
+
* fallback 到 execute-runs/ 下最新的 exec- 前缀目录。
|
|
278
|
+
*
|
|
279
|
+
* @param {string} runtimeRoot - .sillyspec/.runtime 路径
|
|
280
|
+
* @returns {string|null} 当前 run id,null 表示无任何 run
|
|
281
|
+
*/
|
|
282
|
+
export function getLatestExecuteRunId(runtimeRoot) {
|
|
283
|
+
// 优先读标记文件(execute 阶段启动时由 run.js 写入,生命周期内不变)
|
|
284
|
+
const markerPath = join(runtimeRoot, 'current-execute-run-id')
|
|
285
|
+
try {
|
|
286
|
+
if (existsSync(markerPath)) {
|
|
287
|
+
const content = readFileSync(markerPath, 'utf8').trim()
|
|
288
|
+
if (content) return content
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
|
|
292
|
+
// fallback:扫描 execute-runs/ 目录
|
|
293
|
+
const runsDir = join(runtimeRoot, 'execute-runs')
|
|
294
|
+
if (!existsSync(runsDir)) return null
|
|
295
|
+
|
|
296
|
+
try {
|
|
297
|
+
const entries = readdirSync(runsDir, { withFileTypes: true })
|
|
298
|
+
.filter(e => e.isDirectory() && e.name.startsWith('exec-'))
|
|
299
|
+
.map(e => e.name)
|
|
300
|
+
.sort()
|
|
301
|
+
.reverse()
|
|
302
|
+
return entries[0] || null
|
|
303
|
+
} catch {
|
|
304
|
+
return null
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* 确保 task review 目录存在
|
|
310
|
+
* @param {string} runtimeRoot
|
|
311
|
+
* @param {string} executeRunId
|
|
312
|
+
* @param {string} taskId
|
|
313
|
+
* @returns {string} task review 目录路径
|
|
314
|
+
*/
|
|
315
|
+
export function ensureTaskReviewDir(runtimeRoot, executeRunId, taskId) {
|
|
316
|
+
const dir = join(runtimeRoot, 'execute-runs', executeRunId, 'tasks', taskId)
|
|
317
|
+
mkdirSync(dir, { recursive: true })
|
|
318
|
+
return dir
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* 打印校验结果
|
|
323
|
+
* @param {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array }} result
|
|
324
|
+
*/
|
|
325
|
+
export function printReviewResult(result) {
|
|
326
|
+
if (result.ok && result.warnings.length === 0) {
|
|
327
|
+
console.log('\n✅ Task Review Gate — 所有任务评审通过')
|
|
328
|
+
return
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
if (result.errors.length > 0) {
|
|
332
|
+
console.error('\n🚫 Task Review Gate — FAILED')
|
|
333
|
+
for (const err of result.errors) {
|
|
334
|
+
console.error(` - ${err}`)
|
|
335
|
+
}
|
|
336
|
+
console.error('\n 提示:为缺失/失败的任务补充 review.json,然后重新 --done')
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (result.warnings.length > 0) {
|
|
340
|
+
console.warn('\n⚠️ Task Review Gate — WARNING')
|
|
341
|
+
for (const w of result.warnings) {
|
|
342
|
+
console.warn(` - ${w}`)
|
|
343
|
+
}
|
|
344
|
+
console.warn('\n cannot_verify 的 requiredEvidence 将在 verify 阶段校验')
|
|
345
|
+
}
|
|
346
|
+
}
|
package/src/workflow.js
CHANGED
|
@@ -149,10 +149,13 @@ function replaceProjectPlaceholder(wf, projectName) {
|
|
|
149
149
|
* @param {string} cwd - 项目根目录
|
|
150
150
|
* @returns {CheckResult}
|
|
151
151
|
*/
|
|
152
|
-
function checkOutput(outputDef, projectName, cwd) {
|
|
152
|
+
function checkOutput(outputDef, projectName, cwd, specBase) {
|
|
153
|
+
// specBase 优先(平台模式 platformOpts.specRoot,已含或不含 .sillyspec 语义);
|
|
154
|
+
// 未传时回退 join(cwd, '.sillyspec'),等价于旧行为 resolve(cwd, '.sillyspec/...')
|
|
155
|
+
const effectiveBase = specBase || join(cwd, '.sillyspec')
|
|
153
156
|
// 将 <project> 替换为实际项目名
|
|
154
157
|
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
155
|
-
const fullPath = resolve(
|
|
158
|
+
const fullPath = resolve(effectiveBase, rawPath)
|
|
156
159
|
const checks = outputDef.checks || []
|
|
157
160
|
const results = []
|
|
158
161
|
|
|
@@ -241,7 +244,7 @@ function checkOutput(outputDef, projectName, cwd) {
|
|
|
241
244
|
* retry_prompts: [{ role_id, role_name, prompt }]
|
|
242
245
|
* }
|
|
243
246
|
*/
|
|
244
|
-
export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
|
|
247
|
+
export function runPostCheck(wf, cwd, projectName, placeholders = {}, specBase) {
|
|
245
248
|
let resolved = replaceProjectPlaceholder(wf, projectName)
|
|
246
249
|
if (Object.keys(placeholders).length > 0) {
|
|
247
250
|
let json = JSON.stringify(resolved)
|
|
@@ -250,10 +253,11 @@ export function runPostCheck(wf, cwd, projectName, placeholders = {}) {
|
|
|
250
253
|
}
|
|
251
254
|
resolved = JSON.parse(json)
|
|
252
255
|
}
|
|
253
|
-
return _checkWorkflow(resolved, cwd, projectName)
|
|
256
|
+
return _checkWorkflow(resolved, cwd, projectName, specBase)
|
|
254
257
|
}
|
|
255
258
|
|
|
256
|
-
function _checkWorkflow(wf, cwd, projectName) {
|
|
259
|
+
function _checkWorkflow(wf, cwd, projectName, specBase) {
|
|
260
|
+
const effectiveBase = specBase || join(cwd, '.sillyspec')
|
|
257
261
|
const workflowName = wf.name || 'unknown'
|
|
258
262
|
const specVersion = wf.spec_version || wf.version || 0
|
|
259
263
|
const workflowChecks = wf.checks?.workflow_level || []
|
|
@@ -270,7 +274,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
270
274
|
|
|
271
275
|
for (const outputDef of outputDefs) {
|
|
272
276
|
const rawPath = (outputDef.path || '').replace(/<project>/g, projectName)
|
|
273
|
-
const checkResults = checkOutput(outputDef, projectName, cwd)
|
|
277
|
+
const checkResults = checkOutput(outputDef, projectName, cwd, effectiveBase)
|
|
274
278
|
const outputPassed = checkResults.every(c => c.passed)
|
|
275
279
|
|
|
276
280
|
outputs.push({
|
|
@@ -309,7 +313,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
309
313
|
for (const check of workflowChecks) {
|
|
310
314
|
switch (check.type) {
|
|
311
315
|
case 'file_count': {
|
|
312
|
-
const scanDir = join(
|
|
316
|
+
const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
|
|
313
317
|
if (existsSync(scanDir)) {
|
|
314
318
|
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
315
319
|
const min = check.min || 0
|
|
@@ -328,7 +332,7 @@ function _checkWorkflow(wf, cwd, projectName) {
|
|
|
328
332
|
break
|
|
329
333
|
}
|
|
330
334
|
case 'no_empty_files': {
|
|
331
|
-
const scanDir = join(
|
|
335
|
+
const scanDir = join(effectiveBase, 'docs', projectName, check.path || 'scan/')
|
|
332
336
|
if (existsSync(scanDir)) {
|
|
333
337
|
const files = readdirSync(scanDir).filter(f => f.endsWith('.md'))
|
|
334
338
|
let anyEmpty = false
|
|
@@ -13,7 +13,7 @@ roles:
|
|
|
13
13
|
hints:
|
|
14
14
|
grep_patterns: ["class ", "export ", "import ", "schema", "CREATE TABLE"]
|
|
15
15
|
outputs:
|
|
16
|
-
- path: "
|
|
16
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/ARCHITECTURE.md"
|
|
17
17
|
required: true
|
|
18
18
|
checks:
|
|
19
19
|
- type: file_exists
|
|
@@ -33,7 +33,7 @@ roles:
|
|
|
33
33
|
hints:
|
|
34
34
|
grep_patterns: ["function ", "const ", "async ", "try ", "catch "]
|
|
35
35
|
outputs:
|
|
36
|
-
- path: "
|
|
36
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/CONVENTIONS.md"
|
|
37
37
|
required: true
|
|
38
38
|
checks:
|
|
39
39
|
- type: file_exists
|
|
@@ -53,13 +53,13 @@ roles:
|
|
|
53
53
|
hints:
|
|
54
54
|
grep_patterns: ["fetch", "http", "WebSocket", "ws", "chokidar"]
|
|
55
55
|
outputs:
|
|
56
|
-
- path: "
|
|
56
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/STRUCTURE.md"
|
|
57
57
|
required: true
|
|
58
58
|
checks:
|
|
59
59
|
- type: file_exists
|
|
60
60
|
- type: min_lines
|
|
61
61
|
min: 15
|
|
62
|
-
- path: "
|
|
62
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/INTEGRATIONS.md"
|
|
63
63
|
required: true
|
|
64
64
|
checks:
|
|
65
65
|
- type: file_exists
|
|
@@ -77,14 +77,14 @@ roles:
|
|
|
77
77
|
hints:
|
|
78
78
|
grep_patterns: ["TODO", "FIXME", "deprecated", "test", "describe"]
|
|
79
79
|
outputs:
|
|
80
|
-
- path: "
|
|
80
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/TESTING.md"
|
|
81
81
|
required: true
|
|
82
82
|
checks:
|
|
83
83
|
- type: file_exists
|
|
84
84
|
- type: min_lines
|
|
85
85
|
min: 10
|
|
86
86
|
- type: no_placeholder
|
|
87
|
-
- path: "
|
|
87
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/CONCERNS.md"
|
|
88
88
|
required: true
|
|
89
89
|
checks:
|
|
90
90
|
- type: file_exists
|
|
@@ -92,7 +92,7 @@ roles:
|
|
|
92
92
|
min: 10
|
|
93
93
|
- type: contains_sections
|
|
94
94
|
sections: ["代码质量", "依赖风险"]
|
|
95
|
-
- path: "
|
|
95
|
+
- path: "{SPEC_ROOT}/docs/<project>/scan/PROJECT.md"
|
|
96
96
|
required: true
|
|
97
97
|
checks:
|
|
98
98
|
- type: file_exists
|
|
@@ -126,7 +126,7 @@ on_check_failure: prompt_retry
|
|
|
126
126
|
permissions:
|
|
127
127
|
write_mode: direct
|
|
128
128
|
write_scope:
|
|
129
|
-
- "
|
|
129
|
+
- "{SPEC_ROOT}/docs/<project>/scan/"
|
|
130
130
|
allow_shell: true
|
|
131
131
|
allow_network: false
|
|
132
132
|
allow_git: false
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* task-10: 顶层命令别名 doctor/scan/status/quick/explore 转发 runCommand
|
|
3
|
+
*
|
|
4
|
+
* 设计依据:task-10.md §TDD + §验收标准
|
|
5
|
+
* - sillyspec doctor / scan / status / quick / explore 不再落 default 分支报"未知命令"
|
|
6
|
+
* - 行为与 sillyspec run <stage> 字节一致
|
|
7
|
+
* - sillyspec worktree doctor 仍走 worktree 分支
|
|
8
|
+
* - sillyspec foobar 仍报未知命令
|
|
9
|
+
*
|
|
10
|
+
* 断言策略:
|
|
11
|
+
* stage 在空目录下可能因无 .sillyspec 进度而 exit != 0(这是 stage 自身行为,
|
|
12
|
+
* 不属于本任务路由范围)。因此测试只验证"路由正确"——即 stderr 不含"未知命令"
|
|
13
|
+
* 字样(default 分支的特征文案),并且 doctor/scan 两路 stdout 字节一致。
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process'
|
|
17
|
+
import { mkdirSync, rmSync } from 'node:fs'
|
|
18
|
+
import { join, resolve, dirname } from 'node:path'
|
|
19
|
+
import { tmpdir } from 'node:os'
|
|
20
|
+
import { fileURLToPath } from 'node:url'
|
|
21
|
+
|
|
22
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
23
|
+
const __dirname = dirname(__filename)
|
|
24
|
+
const cliBin = resolve(__dirname, '..', 'bin', 'sillyspec.js')
|
|
25
|
+
|
|
26
|
+
let passed = 0
|
|
27
|
+
let failed = 0
|
|
28
|
+
|
|
29
|
+
function assert(cond, msg) {
|
|
30
|
+
if (cond) {
|
|
31
|
+
console.log(` ✅ PASS: ${msg}`)
|
|
32
|
+
passed++
|
|
33
|
+
} else {
|
|
34
|
+
console.log(` ❌ FAIL: ${msg}`)
|
|
35
|
+
failed++
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function runCLI(args, cwd) {
|
|
40
|
+
const res = spawnSync(process.execPath, [cliBin, ...args], {
|
|
41
|
+
cwd,
|
|
42
|
+
encoding: 'utf8',
|
|
43
|
+
timeout: 15000,
|
|
44
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
45
|
+
})
|
|
46
|
+
return {
|
|
47
|
+
stdout: res.stdout || '',
|
|
48
|
+
stderr: res.stderr || '',
|
|
49
|
+
status: res.status,
|
|
50
|
+
combined: (res.stdout || '') + (res.stderr || ''),
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function cleanSillySpec(cwd) {
|
|
55
|
+
// 清掉 sillyspec 写入 cwd 的进度副作用,保证两路字节级环境一致
|
|
56
|
+
try { rmSync(join(cwd, '.sillyspec'), { recursive: true, force: true }) } catch {}
|
|
57
|
+
try { rmSync(join(cwd, '.sillyspec-platform.json'), { force: true }) } catch {}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const tmpRoot = join(tmpdir(), `sillyspec-cli-aliases-${Date.now()}`)
|
|
61
|
+
mkdirSync(tmpRoot, { recursive: true })
|
|
62
|
+
|
|
63
|
+
try {
|
|
64
|
+
// ── Red/Green: 5 个顶层命令不再报"未知命令" ──
|
|
65
|
+
const aliases = ['doctor', 'scan', 'status', 'quick', 'explore']
|
|
66
|
+
console.log('\n=== Test 1: 顶层命令别名不落 default 分支 ===')
|
|
67
|
+
for (const stage of aliases) {
|
|
68
|
+
const res = runCLI([stage], tmpRoot)
|
|
69
|
+
const hitUnknown =
|
|
70
|
+
res.combined.includes('未知命令') ||
|
|
71
|
+
/unknown command/i.test(res.combined)
|
|
72
|
+
assert(
|
|
73
|
+
!hitUnknown,
|
|
74
|
+
`sillyspec ${stage} 不报"未知命令" (exit=${res.status})`
|
|
75
|
+
)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── Green: doctor 顶层别名 与 sillyspec run doctor 字节一致 ──
|
|
79
|
+
// 两路必须在字节级相同环境下运行:同 cwd + 每次跑前清空 .sillyspec
|
|
80
|
+
// (否则 progress 持久化会让第二次跑读到旧数据触发平台同步检查)
|
|
81
|
+
console.log('\n=== Test 2: sillyspec doctor 与 sillyspec run doctor 等价 ===')
|
|
82
|
+
{
|
|
83
|
+
const cwd = join(tmpRoot, 'doctor-cmp')
|
|
84
|
+
mkdirSync(cwd, { recursive: true })
|
|
85
|
+
cleanSillySpec(cwd)
|
|
86
|
+
const top = runCLI(['doctor'], cwd)
|
|
87
|
+
cleanSillySpec(cwd)
|
|
88
|
+
const viaRun = runCLI(['run', 'doctor'], cwd)
|
|
89
|
+
assert(
|
|
90
|
+
top.status === viaRun.status,
|
|
91
|
+
`exit code 一致: doctor=${top.status}, run doctor=${viaRun.status}`
|
|
92
|
+
)
|
|
93
|
+
assert(
|
|
94
|
+
top.stdout === viaRun.stdout,
|
|
95
|
+
`stdout 字节一致 (len=${top.stdout.length})`
|
|
96
|
+
)
|
|
97
|
+
assert(
|
|
98
|
+
top.stderr === viaRun.stderr,
|
|
99
|
+
`stderr 字节一致 (len=${top.stderr.length})`
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ── Green: scan 顶层别名 与 sillyspec run scan 字节一致 ──
|
|
104
|
+
console.log('\n=== Test 3: sillyspec scan 与 sillyspec run scan 等价 ===')
|
|
105
|
+
{
|
|
106
|
+
const cwd = join(tmpRoot, 'scan-cmp')
|
|
107
|
+
mkdirSync(cwd, { recursive: true })
|
|
108
|
+
cleanSillySpec(cwd)
|
|
109
|
+
const top = runCLI(['scan'], cwd)
|
|
110
|
+
cleanSillySpec(cwd)
|
|
111
|
+
const viaRun = runCLI(['run', 'scan'], cwd)
|
|
112
|
+
assert(
|
|
113
|
+
top.status === viaRun.status,
|
|
114
|
+
`exit code 一致: scan=${top.status}, run scan=${viaRun.status}`
|
|
115
|
+
)
|
|
116
|
+
assert(
|
|
117
|
+
top.stdout === viaRun.stdout,
|
|
118
|
+
`stdout 字节一致 (len=${top.stdout.length})`
|
|
119
|
+
)
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ── 回归: worktree doctor 走 worktree 分支(与顶层 doctor 不同) ──
|
|
123
|
+
console.log('\n=== Test 4: sillyspec worktree doctor 走 worktree 分支 ===')
|
|
124
|
+
{
|
|
125
|
+
const res = runCLI(['worktree', 'doctor'], tmpRoot)
|
|
126
|
+
// worktree doctor 不应报顶层 default 的"未知命令",也不应报"未知阶段"
|
|
127
|
+
const hitUnknownCmd =
|
|
128
|
+
res.combined.includes('未知命令') && !/worktree/.test(res.combined)
|
|
129
|
+
assert(!hitUnknownCmd, `worktree doctor 不报顶层未知命令`)
|
|
130
|
+
// worktree 子命令 default 分支会输出"未知子命令: worktree",这里 doctor 合法不应出现
|
|
131
|
+
assert(
|
|
132
|
+
!res.combined.includes('未知子命令'),
|
|
133
|
+
`worktree doctor 不是未知子命令`
|
|
134
|
+
)
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ── 回归: foobar 仍落 default 报未知命令 ──
|
|
138
|
+
console.log('\n=== Test 5: sillyspec foobar 仍报未知命令 ===')
|
|
139
|
+
{
|
|
140
|
+
const res = runCLI(['foobar'], tmpRoot)
|
|
141
|
+
assert(
|
|
142
|
+
res.combined.includes('未知命令'),
|
|
143
|
+
`foobar 命中 default 分支,报"未知命令"`
|
|
144
|
+
)
|
|
145
|
+
assert(
|
|
146
|
+
res.status !== 0,
|
|
147
|
+
`foobar exit code 非 0 (got ${res.status})`
|
|
148
|
+
)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// ── 选项透传: sillyspec doctor --json 与 sillyspec run doctor --json 等价 ──
|
|
152
|
+
console.log('\n=== Test 6: doctor --json 选项透传正确 ===')
|
|
153
|
+
{
|
|
154
|
+
const top = runCLI(['doctor', '--json'], tmpRoot)
|
|
155
|
+
const viaRun = runCLI(['run', 'doctor', '--json'], tmpRoot)
|
|
156
|
+
assert(
|
|
157
|
+
top.stdout === viaRun.stdout,
|
|
158
|
+
`doctor --json stdout 与 run doctor --json 一致 (len=${top.stdout.length})`
|
|
159
|
+
)
|
|
160
|
+
assert(
|
|
161
|
+
top.status === viaRun.status,
|
|
162
|
+
`doctor --json exit 与 run doctor --json 一致`
|
|
163
|
+
)
|
|
164
|
+
}
|
|
165
|
+
} finally {
|
|
166
|
+
try {
|
|
167
|
+
rmSync(tmpRoot, { recursive: true, force: true })
|
|
168
|
+
} catch {}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
172
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
173
|
+
console.log(`${'='.repeat(50)}`)
|
|
174
|
+
process.exit(failed > 0 ? 1 : 0)
|