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.
@@ -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(cwd, rawPath)
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(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
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(cwd, '.sillyspec', 'docs', projectName, check.path || 'scan/')
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: ".sillyspec/docs/<project>/scan/ARCHITECTURE.md"
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: ".sillyspec/docs/<project>/scan/CONVENTIONS.md"
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: ".sillyspec/docs/<project>/scan/STRUCTURE.md"
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: ".sillyspec/docs/<project>/scan/INTEGRATIONS.md"
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: ".sillyspec/docs/<project>/scan/TESTING.md"
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: ".sillyspec/docs/<project>/scan/CONCERNS.md"
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: ".sillyspec/docs/<project>/scan/PROJECT.md"
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
- - ".sillyspec/docs/<project>/scan/"
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)