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
|
@@ -0,0 +1,501 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* repeatableWait / requiresWait / 普通 wait 门控测试
|
|
3
|
+
*
|
|
4
|
+
* 测试点:
|
|
5
|
+
* 1. repeatableWait --continue 后仍 pending(不 completed)
|
|
6
|
+
* 2. requiresWait --continue 后仍 pending(不 completed)
|
|
7
|
+
* 3. 普通 wait --continue 后 completed
|
|
8
|
+
* 4. requiresWait 直接 --done 被拒绝
|
|
9
|
+
* 5. repeatableWait 多轮后 --done 可推进
|
|
10
|
+
* 6. brainstorm validator 检测四件套缺失
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { join, resolve, basename, dirname } from 'path'
|
|
14
|
+
import { existsSync, mkdirSync, writeFileSync, rmSync, readFileSync } from 'fs'
|
|
15
|
+
import { fileURLToPath, pathToFileURL } from 'url'
|
|
16
|
+
import { execSync } from 'child_process'
|
|
17
|
+
import { tmpdir } from 'os'
|
|
18
|
+
|
|
19
|
+
const __filename = fileURLToPath(import.meta.url)
|
|
20
|
+
const __dirname = dirname(__filename)
|
|
21
|
+
const root = resolve(__dirname, '..')
|
|
22
|
+
const binCLI = join(root, 'bin', 'sillyspec.js')
|
|
23
|
+
|
|
24
|
+
function imp(path) {
|
|
25
|
+
return import(pathToFileURL(path).href)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let passed = 0
|
|
29
|
+
let failed = 0
|
|
30
|
+
|
|
31
|
+
function assert(condition, msg) {
|
|
32
|
+
if (condition) {
|
|
33
|
+
console.log(` ✅ PASS: ${msg}`)
|
|
34
|
+
passed++
|
|
35
|
+
} else {
|
|
36
|
+
console.log(` ❌ FAIL: ${msg}`)
|
|
37
|
+
failed++
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function run(cmd, opts = {}) {
|
|
42
|
+
try {
|
|
43
|
+
return execSync(cmd, { encoding: 'utf8', timeout: 10000, ...opts })
|
|
44
|
+
} catch (e) {
|
|
45
|
+
return (e.stdout || '') + (e.stderr || '')
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function tmpDir(label) {
|
|
50
|
+
const dir = join(tmpdir(), `sillyspec-wait-test-${label}-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`)
|
|
51
|
+
mkdirSync(dir, { recursive: true })
|
|
52
|
+
return dir
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function cleanup(dir) {
|
|
56
|
+
try { rmSync(dir, { recursive: true, force: true }) } catch {}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── Setup helper: create initialized project with a change ──
|
|
60
|
+
function setupProject(label) {
|
|
61
|
+
const projectDir = tmpDir(label)
|
|
62
|
+
run(`node "${binCLI}" init "${projectDir}"`)
|
|
63
|
+
// Create a change directory with four-piece files
|
|
64
|
+
const changeName = '2026-06-14-test-change'
|
|
65
|
+
const changeDir = join(projectDir, '.sillyspec', 'changes', changeName)
|
|
66
|
+
mkdirSync(changeDir, { recursive: true })
|
|
67
|
+
// Register the change in DB by running a harmless command
|
|
68
|
+
const specDir = join(projectDir, '.sillyspec')
|
|
69
|
+
return { projectDir, changeName, changeDir, specDir }
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function writeFourFiles(changeDir) {
|
|
73
|
+
writeFileSync(join(changeDir, 'design.md'), `# Design\n\n## 背景\nTest\n## 文件变更清单\n| 操作 | 文件 | 说明 |\n## 风险登记\n| 编号 | 风险 |\n## 自审\nOK\n`)
|
|
74
|
+
writeFileSync(join(changeDir, 'proposal.md'), `# Proposal\n## 动机\nTest\n## 不在范围内\n- Nothing\n## 成功标准\nWorks\n`)
|
|
75
|
+
writeFileSync(join(changeDir, 'requirements.md'), `# Requirements\n## 角色\n| 角色 |\n## 功能需求\n### FR-01: Test\nGiven X\nWhen Y\nThen Z\n`)
|
|
76
|
+
writeFileSync(join(changeDir, 'tasks.md'), `# Tasks\n- [ ] Task 1: do something\n- [ ] Task 2: do more\n`)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── Read progress from DB ──
|
|
80
|
+
async function readProgress(projectDir, changeName) {
|
|
81
|
+
const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
|
|
82
|
+
const pm = new ProgressManager()
|
|
83
|
+
const progress = await pm.read(projectDir, changeName)
|
|
84
|
+
return progress
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function writeProgress(projectDir, changeName, progress) {
|
|
88
|
+
const { ProgressManager } = await imp(join(root, 'src', 'progress.js'))
|
|
89
|
+
const pm = new ProgressManager()
|
|
90
|
+
await pm._write(projectDir, progress, changeName)
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ================================================================
|
|
94
|
+
// Test 1: repeatableWait --continue 后仍 pending
|
|
95
|
+
// ================================================================
|
|
96
|
+
console.log('\n=== Test 1: repeatableWait --continue 后仍 pending ===')
|
|
97
|
+
{
|
|
98
|
+
const { projectDir, changeName } = setupProject('repeatable')
|
|
99
|
+
|
|
100
|
+
// Init brainstorm stage for the change
|
|
101
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
102
|
+
|
|
103
|
+
// Read progress, find the repeatable step (对话式探索 = step 6, index 5)
|
|
104
|
+
const progress = await readProgress(projectDir, changeName)
|
|
105
|
+
const brainstormData = progress.stages.brainstorm
|
|
106
|
+
assert(brainstormData && brainstormData.steps, 'brainstorm steps initialized')
|
|
107
|
+
|
|
108
|
+
// Find 对话式探索 step index
|
|
109
|
+
const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
|
|
110
|
+
assert(exploreIdx !== -1, '找到"对话式探索"步骤')
|
|
111
|
+
|
|
112
|
+
// Set steps before explore to completed, set explore to waiting
|
|
113
|
+
for (let i = 0; i < exploreIdx; i++) {
|
|
114
|
+
brainstormData.steps[i].status = 'completed'
|
|
115
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
116
|
+
}
|
|
117
|
+
brainstormData.steps[exploreIdx].status = 'waiting'
|
|
118
|
+
brainstormData.steps[exploreIdx].waitReason = '等待用户回答需求问题'
|
|
119
|
+
brainstormData.steps[exploreIdx].waitOptions = ['继续补充', '信息够了']
|
|
120
|
+
brainstormData.steps[exploreIdx].waitedAt = new Date().toISOString()
|
|
121
|
+
brainstormData.steps[exploreIdx].output = '你到底想要什么?'
|
|
122
|
+
await writeProgress(projectDir, changeName, progress)
|
|
123
|
+
|
|
124
|
+
// Now --continue with an answer
|
|
125
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "我想要一个用户管理系统" --change ${changeName}`)
|
|
126
|
+
assert(output.includes('回到当前步骤'), 'continue 输出包含"回到当前步骤"')
|
|
127
|
+
assert(output.includes('🔁'), 'continue 输出包含 🔁 标记')
|
|
128
|
+
|
|
129
|
+
// Read progress again, check step is still pending (not completed)
|
|
130
|
+
const progress2 = await readProgress(projectDir, changeName)
|
|
131
|
+
const step = progress2.stages.brainstorm.steps[exploreIdx]
|
|
132
|
+
assert(step.status === 'pending', `"对话式探索" --continue 后状态是 pending (实际: ${step.status})`)
|
|
133
|
+
assert(Array.isArray(step.waitAnswers) && step.waitAnswers.length === 1, `waitAnswers 有 1 条记录 (实际: ${JSON.stringify(step.waitAnswers)})`)
|
|
134
|
+
assert(step.waitRound === 1, `waitRound === 1 (实际: ${step.waitRound})`)
|
|
135
|
+
|
|
136
|
+
cleanup(projectDir)
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ================================================================
|
|
140
|
+
// Test 2: requiresWait --continue 后仍 pending
|
|
141
|
+
// ================================================================
|
|
142
|
+
console.log('\n=== Test 2: requiresWait --continue 后仍 pending ===')
|
|
143
|
+
{
|
|
144
|
+
const { projectDir, changeName } = setupProject('requires')
|
|
145
|
+
|
|
146
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
147
|
+
|
|
148
|
+
const progress = await readProgress(projectDir, changeName)
|
|
149
|
+
const brainstormData = progress.stages.brainstorm
|
|
150
|
+
|
|
151
|
+
// Find 提出 2-3 种方案 step
|
|
152
|
+
const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
|
|
153
|
+
assert(proposeIdx !== -1, '找到"提出 2-3 种方案"步骤')
|
|
154
|
+
|
|
155
|
+
// Set all steps before it to completed, set it to waiting
|
|
156
|
+
for (let i = 0; i < proposeIdx; i++) {
|
|
157
|
+
brainstormData.steps[i].status = 'completed'
|
|
158
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
159
|
+
}
|
|
160
|
+
brainstormData.steps[proposeIdx].status = 'waiting'
|
|
161
|
+
brainstormData.steps[proposeIdx].waitReason = '等待用户选择方案'
|
|
162
|
+
brainstormData.steps[proposeIdx].waitOptions = ['方案A', '方案B', '方案C']
|
|
163
|
+
brainstormData.steps[proposeIdx].waitedAt = new Date().toISOString()
|
|
164
|
+
brainstormData.steps[proposeIdx].output = '方案A vs 方案B'
|
|
165
|
+
await writeProgress(projectDir, changeName, progress)
|
|
166
|
+
|
|
167
|
+
// --continue with selection
|
|
168
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "方案A" --change ${changeName}`)
|
|
169
|
+
assert(output.includes('回到当前步骤'), 'continue 输出包含"回到当前步骤"')
|
|
170
|
+
|
|
171
|
+
// Check step is pending, not completed
|
|
172
|
+
const progress2 = await readProgress(projectDir, changeName)
|
|
173
|
+
const step = progress2.stages.brainstorm.steps[proposeIdx]
|
|
174
|
+
assert(step.status === 'pending', `"提出方案" --continue 后状态是 pending (实际: ${step.status})`)
|
|
175
|
+
assert(step.waitAnswer === '方案A', 'waitAnswer === 方案A')
|
|
176
|
+
|
|
177
|
+
cleanup(projectDir)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ================================================================
|
|
181
|
+
// Test 3: 普通 wait --continue 后 completed
|
|
182
|
+
// ================================================================
|
|
183
|
+
console.log('\n=== Test 3: 普通 wait --continue 后 completed ===')
|
|
184
|
+
{
|
|
185
|
+
const { projectDir, changeName } = setupProject('normal-wait')
|
|
186
|
+
|
|
187
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
188
|
+
|
|
189
|
+
const progress = await readProgress(projectDir, changeName)
|
|
190
|
+
const brainstormData = progress.stages.brainstorm
|
|
191
|
+
|
|
192
|
+
// Find 状态检查 (step 0) — it's a normal step, not requiresWait
|
|
193
|
+
// Simulate it being in waiting state (as if someone manually --wait'd it)
|
|
194
|
+
brainstormData.steps[0].status = 'waiting'
|
|
195
|
+
brainstormData.steps[0].waitReason = '等待原因'
|
|
196
|
+
brainstormData.steps[0].waitedAt = new Date().toISOString()
|
|
197
|
+
await writeProgress(projectDir, changeName, progress)
|
|
198
|
+
|
|
199
|
+
// --continue
|
|
200
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "继续" --change ${changeName}`)
|
|
201
|
+
|
|
202
|
+
const progress2 = await readProgress(projectDir, changeName)
|
|
203
|
+
const step = progress2.stages.brainstorm.steps[0]
|
|
204
|
+
assert(step.status === 'completed', `普通 wait --continue 后 completed (实际: ${step.status})`)
|
|
205
|
+
|
|
206
|
+
cleanup(projectDir)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ================================================================
|
|
210
|
+
// Test 4: requiresWait 直接 --done 被拒绝
|
|
211
|
+
// ================================================================
|
|
212
|
+
console.log('\n=== Test 4: requiresWait 直接 --done 被拒绝 ===')
|
|
213
|
+
{
|
|
214
|
+
const { projectDir, changeName } = setupProject('refuse-done')
|
|
215
|
+
|
|
216
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
217
|
+
|
|
218
|
+
const progress = await readProgress(projectDir, changeName)
|
|
219
|
+
const brainstormData = progress.stages.brainstorm
|
|
220
|
+
|
|
221
|
+
// Find 提出 2-3 种方案 (requiresWait=true)
|
|
222
|
+
const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
|
|
223
|
+
for (let i = 0; i < proposeIdx; i++) {
|
|
224
|
+
brainstormData.steps[i].status = 'completed'
|
|
225
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
226
|
+
}
|
|
227
|
+
// Step is pending, no waitAnswer
|
|
228
|
+
brainstormData.steps[proposeIdx].status = 'pending'
|
|
229
|
+
await writeProgress(projectDir, changeName, progress)
|
|
230
|
+
|
|
231
|
+
// Try --done directly (should fail because requiresWait && !waitAnswer)
|
|
232
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "推荐方案A" --change ${changeName}`)
|
|
233
|
+
assert(output.includes('必须先等待用户输入') || output.includes('不能直接'), 'requiresWait 步骤直接 --done 被拒绝')
|
|
234
|
+
|
|
235
|
+
// Verify step is still pending
|
|
236
|
+
const progress2 = await readProgress(projectDir, changeName)
|
|
237
|
+
const step = progress2.stages.brainstorm.steps[proposeIdx]
|
|
238
|
+
assert(step.status === 'pending', `拒绝后步骤仍是 pending (实际: ${step.status})`)
|
|
239
|
+
|
|
240
|
+
cleanup(projectDir)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// ================================================================
|
|
244
|
+
// Test 5: repeatableWait 多轮收集后 --done 可推进
|
|
245
|
+
// ================================================================
|
|
246
|
+
console.log('\n=== Test 5: repeatableWait 多轮后 --done 可推进 ===')
|
|
247
|
+
{
|
|
248
|
+
const { projectDir, changeName } = setupProject('multi-round')
|
|
249
|
+
|
|
250
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
251
|
+
|
|
252
|
+
const progress = await readProgress(projectDir, changeName)
|
|
253
|
+
const brainstormData = progress.stages.brainstorm
|
|
254
|
+
|
|
255
|
+
const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
|
|
256
|
+
for (let i = 0; i < exploreIdx; i++) {
|
|
257
|
+
brainstormData.steps[i].status = 'completed'
|
|
258
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
259
|
+
}
|
|
260
|
+
brainstormData.steps[exploreIdx].status = 'waiting'
|
|
261
|
+
brainstormData.steps[exploreIdx].waitReason = '等待用户回答'
|
|
262
|
+
brainstormData.steps[exploreIdx].waitedAt = new Date().toISOString()
|
|
263
|
+
brainstormData.steps[exploreIdx].output = '问题1'
|
|
264
|
+
await writeProgress(projectDir, changeName, progress)
|
|
265
|
+
|
|
266
|
+
// Round 1
|
|
267
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "回答1" --change ${changeName}`)
|
|
268
|
+
let p2 = await readProgress(projectDir, changeName)
|
|
269
|
+
assert(p2.stages.brainstorm.steps[exploreIdx].status === 'pending', '第1轮后仍 pending')
|
|
270
|
+
assert(p2.stages.brainstorm.steps[exploreIdx].waitAnswers.length === 1, '1 条历史回答')
|
|
271
|
+
|
|
272
|
+
// Simulate another --wait cycle
|
|
273
|
+
p2.stages.brainstorm.steps[exploreIdx].status = 'waiting'
|
|
274
|
+
p2.stages.brainstorm.steps[exploreIdx].waitReason = '等待用户回答'
|
|
275
|
+
p2.stages.brainstorm.steps[exploreIdx].waitedAt = new Date().toISOString()
|
|
276
|
+
p2.stages.brainstorm.steps[exploreIdx].output = '问题2'
|
|
277
|
+
await writeProgress(projectDir, changeName, p2)
|
|
278
|
+
|
|
279
|
+
// Round 2
|
|
280
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "回答2" --change ${changeName}`)
|
|
281
|
+
let p3 = await readProgress(projectDir, changeName)
|
|
282
|
+
assert(p3.stages.brainstorm.steps[exploreIdx].status === 'pending', '第2轮后仍 pending')
|
|
283
|
+
assert(p3.stages.brainstorm.steps[exploreIdx].waitAnswers.length === 2, '2 条历史回答')
|
|
284
|
+
|
|
285
|
+
// Now agent does --done (has waitAnswer so should pass requiresWait gate)
|
|
286
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "需求已明确" --change ${changeName}`)
|
|
287
|
+
let p4 = await readProgress(projectDir, changeName)
|
|
288
|
+
assert(p4.stages.brainstorm.steps[exploreIdx].status === 'completed', '--done 后步骤 completed')
|
|
289
|
+
|
|
290
|
+
cleanup(projectDir)
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// ================================================================
|
|
294
|
+
// Test 6: brainstorm validator 检测四件套缺失
|
|
295
|
+
// ================================================================
|
|
296
|
+
console.log('\n=== Test 6: brainstorm validator 检测四件套缺失 ===')
|
|
297
|
+
{
|
|
298
|
+
const { projectDir, changeName, changeDir } = setupProject('validator')
|
|
299
|
+
// Don't write the four files — validator should catch this
|
|
300
|
+
|
|
301
|
+
const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
|
|
302
|
+
const result = runValidators('brainstorm', projectDir, changeName, {})
|
|
303
|
+
assert(result.ok === false, '四件套缺失时 ok=false')
|
|
304
|
+
assert(result.errors.length >= 4, `至少 4 个 error (实际: ${result.errors.length})`)
|
|
305
|
+
assert(result.errors.some(e => e.includes('design.md')), 'error 包含 design.md')
|
|
306
|
+
assert(result.errors.some(e => e.includes('proposal.md')), 'error 包含 proposal.md')
|
|
307
|
+
assert(result.errors.some(e => e.includes('requirements.md')), 'error 包含 requirements.md')
|
|
308
|
+
assert(result.errors.some(e => e.includes('tasks.md')), 'error 包含 tasks.md')
|
|
309
|
+
|
|
310
|
+
// Now write all four files
|
|
311
|
+
writeFourFiles(changeDir)
|
|
312
|
+
const result2 = runValidators('brainstorm', projectDir, changeName, {})
|
|
313
|
+
assert(result2.ok === true, '四件套存在时 ok=true')
|
|
314
|
+
assert(result2.errors.length === 0, '四件套存在时无 error')
|
|
315
|
+
|
|
316
|
+
cleanup(projectDir)
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// ================================================================
|
|
320
|
+
// Test 7: tasks.md 空列表只 warning 不 error
|
|
321
|
+
// ================================================================
|
|
322
|
+
console.log('\n=== Test 7: tasks.md 空列表只 warning 不 error ===')
|
|
323
|
+
{
|
|
324
|
+
const { projectDir, changeName, changeDir } = setupProject('empty-tasks')
|
|
325
|
+
|
|
326
|
+
writeFileSync(join(changeDir, 'design.md'), `# Design\n## 背景\n## 文件变更清单\n## 风险登记\n## 自审\n`)
|
|
327
|
+
writeFileSync(join(changeDir, 'proposal.md'), `# Proposal\n## 不在范围内\n- x\n`)
|
|
328
|
+
writeFileSync(join(changeDir, 'requirements.md'), `# Requirements\n### FR-01: x\nGiven\nWhen\nThen\n`)
|
|
329
|
+
writeFileSync(join(changeDir, 'tasks.md'), `# Tasks\n(no items)\n`) // no list items
|
|
330
|
+
|
|
331
|
+
const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
|
|
332
|
+
const result = runValidators('brainstorm', projectDir, changeName, {})
|
|
333
|
+
assert(result.ok === true, '空 tasks.md → ok=true (文件存在)')
|
|
334
|
+
assert(result.warnings.some(w => w.includes('任务列表')), '空 tasks.md → warning about 任务列表')
|
|
335
|
+
|
|
336
|
+
cleanup(projectDir)
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
// ================================================================
|
|
341
|
+
// Test 8: 多个 waiting 步骤 → --continue 必须报错
|
|
342
|
+
// ================================================================
|
|
343
|
+
console.log('\n=== Test 8: 多个 waiting 步骤 → --continue 报错 ===')
|
|
344
|
+
{
|
|
345
|
+
const { projectDir, changeName } = setupProject('multi-waiting')
|
|
346
|
+
|
|
347
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
348
|
+
|
|
349
|
+
const progress = await readProgress(projectDir, changeName)
|
|
350
|
+
const brainstormData = progress.stages.brainstorm
|
|
351
|
+
// Set two steps to waiting
|
|
352
|
+
brainstormData.steps[0].status = 'waiting'
|
|
353
|
+
brainstormData.steps[0].waitReason = '等待1'
|
|
354
|
+
brainstormData.steps[0].waitedAt = new Date().toISOString()
|
|
355
|
+
brainstormData.steps[1].status = 'waiting'
|
|
356
|
+
brainstormData.steps[1].waitReason = '等待2'
|
|
357
|
+
brainstormData.steps[1].waitedAt = new Date().toISOString()
|
|
358
|
+
await writeProgress(projectDir, changeName, progress)
|
|
359
|
+
|
|
360
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --continue --answer "test" --change ${changeName}`)
|
|
361
|
+
assert(output.includes('检测到') && output.includes('等待中的步骤'), '多个 waiting 步骤时报错')
|
|
362
|
+
|
|
363
|
+
cleanup(projectDir)
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// ================================================================
|
|
367
|
+
// Test 9: --skip-approval 不能绕过 requiresWait
|
|
368
|
+
// ================================================================
|
|
369
|
+
console.log('\n=== Test 9: --skip-approval 不能绕过 requiresWait ===')
|
|
370
|
+
{
|
|
371
|
+
const { projectDir, changeName } = setupProject('skip-approval')
|
|
372
|
+
|
|
373
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
374
|
+
|
|
375
|
+
const progress = await readProgress(projectDir, changeName)
|
|
376
|
+
const brainstormData = progress.stages.brainstorm
|
|
377
|
+
const proposeIdx = brainstormData.steps.findIndex(s => s.name === '提出 2-3 种方案')
|
|
378
|
+
for (let i = 0; i < proposeIdx; i++) {
|
|
379
|
+
brainstormData.steps[i].status = 'completed'
|
|
380
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
381
|
+
}
|
|
382
|
+
brainstormData.steps[proposeIdx].status = 'pending'
|
|
383
|
+
await writeProgress(projectDir, changeName, progress)
|
|
384
|
+
|
|
385
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --skip-approval --output "推荐方案A" --change ${changeName}`)
|
|
386
|
+
assert(output.includes('必须先等待用户输入') || output.includes('不能直接'), '--skip-approval 无法绕过 requiresWait')
|
|
387
|
+
|
|
388
|
+
cleanup(projectDir)
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// ================================================================
|
|
392
|
+
// Test 10: repeatableWait 达到 maxWaitRounds 后再次 --wait 被拒绝
|
|
393
|
+
// ================================================================
|
|
394
|
+
console.log('\n=== Test 10: maxWaitRounds 达到上限后 --wait 被拒绝 ===')
|
|
395
|
+
{
|
|
396
|
+
const { projectDir, changeName } = setupProject('max-rounds')
|
|
397
|
+
|
|
398
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
399
|
+
|
|
400
|
+
const progress = await readProgress(projectDir, changeName)
|
|
401
|
+
const brainstormData = progress.stages.brainstorm
|
|
402
|
+
const exploreIdx = brainstormData.steps.findIndex(s => s.name === '对话式探索')
|
|
403
|
+
for (let i = 0; i < exploreIdx; i++) {
|
|
404
|
+
brainstormData.steps[i].status = 'completed'
|
|
405
|
+
brainstormData.steps[i].completedAt = new Date().toISOString()
|
|
406
|
+
}
|
|
407
|
+
// Simulate already having maxRounds (3) rounds
|
|
408
|
+
brainstormData.steps[exploreIdx].status = 'pending'
|
|
409
|
+
brainstormData.steps[exploreIdx].waitRound = 3
|
|
410
|
+
brainstormData.steps[exploreIdx].maxWaitRounds = 3
|
|
411
|
+
brainstormData.steps[exploreIdx].waitAnswer = 'previous answer'
|
|
412
|
+
await writeProgress(projectDir, changeName, progress)
|
|
413
|
+
|
|
414
|
+
// Try to --wait again (should be refused)
|
|
415
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --wait --reason "还想再问" --output "再问一个问题" --change ${changeName}`)
|
|
416
|
+
assert(output.includes('已达到最大等待轮次') || output.includes('maxWaitRounds'), '达到 maxWaitRounds 后 --wait 被拒绝')
|
|
417
|
+
|
|
418
|
+
// But --done should still work (has waitAnswer)
|
|
419
|
+
const output2 = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --done --output "需求已明确" --change ${changeName}`)
|
|
420
|
+
const progress2 = await readProgress(projectDir, changeName)
|
|
421
|
+
const step = progress2.stages.brainstorm.steps[exploreIdx]
|
|
422
|
+
assert(step.status === 'completed', '达到上限后 --done 仍可推进')
|
|
423
|
+
|
|
424
|
+
cleanup(projectDir)
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ================================================================
|
|
428
|
+
// Test 11: specRoot 模式下 changes 缺失 → validator error
|
|
429
|
+
// ================================================================
|
|
430
|
+
console.log('\n=== Test 11: specRoot 模式下 changes 缺失 → fail-fast ===')
|
|
431
|
+
{
|
|
432
|
+
const specRoot = tmpDir('specroot-nofail')
|
|
433
|
+
const { runValidators } = await imp(join(root, 'src', 'stage-contract.js'))
|
|
434
|
+
// specRoot exists but has no changes/ directory
|
|
435
|
+
const result = runValidators('brainstorm', '/some/project', 'test-change', { specRoot })
|
|
436
|
+
assert(result.ok === false, 'specRoot 缺 changes 目录时 ok=false')
|
|
437
|
+
assert(result.errors.some(e => e.includes('缺少 changes 目录')), 'error 包含「缺少 changes 目录」')
|
|
438
|
+
|
|
439
|
+
cleanup(specRoot)
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
// ================================================================
|
|
443
|
+
// Test 12: waitRound=0 正确写入读取(不被 || 吞掉)
|
|
444
|
+
// ================================================================
|
|
445
|
+
console.log('\n=== Test 12: waitRound=0 正确持久化 ===')
|
|
446
|
+
{
|
|
447
|
+
const { projectDir, changeName } = setupProject('waitround-zero')
|
|
448
|
+
|
|
449
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
450
|
+
|
|
451
|
+
const progress = await readProgress(projectDir, changeName)
|
|
452
|
+
const brainstormData = progress.stages.brainstorm
|
|
453
|
+
// Manually set waitRound=0 (simulates edge case)
|
|
454
|
+
brainstormData.steps[0].status = 'pending'
|
|
455
|
+
brainstormData.steps[0].waitRound = 0
|
|
456
|
+
brainstormData.steps[0].maxWaitRounds = 3
|
|
457
|
+
await writeProgress(projectDir, changeName, progress)
|
|
458
|
+
|
|
459
|
+
const p2 = await readProgress(projectDir, changeName)
|
|
460
|
+
// waitRound=0 should be read back as 0 (not null/undefined)
|
|
461
|
+
assert(p2.stages.brainstorm.steps[0].waitRound === 0, `waitRound=0 正确读取 (实际: ${p2.stages.brainstorm.steps[0].waitRound})`)
|
|
462
|
+
assert(p2.stages.brainstorm.steps[0].maxWaitRounds === 3, `maxWaitRounds=3 正确读取 (实际: ${p2.stages.brainstorm.steps[0].maxWaitRounds})`)
|
|
463
|
+
|
|
464
|
+
cleanup(projectDir)
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
// ================================================================
|
|
468
|
+
// Test 13: 已有 waiting 步骤时 --wait 被拒绝
|
|
469
|
+
// ================================================================
|
|
470
|
+
console.log('\n=== Test 13: 已有 waiting 步骤时 --wait 被拒绝 ===')
|
|
471
|
+
{
|
|
472
|
+
const { projectDir, changeName } = setupProject('existing-wait')
|
|
473
|
+
|
|
474
|
+
run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --change ${changeName}`)
|
|
475
|
+
|
|
476
|
+
const progress = await readProgress(projectDir, changeName)
|
|
477
|
+
const brainstormData = progress.stages.brainstorm
|
|
478
|
+
// Set step 0 to waiting
|
|
479
|
+
brainstormData.steps[0].status = 'waiting'
|
|
480
|
+
brainstormData.steps[0].waitReason = '等待中'
|
|
481
|
+
brainstormData.steps[0].waitedAt = new Date().toISOString()
|
|
482
|
+
await writeProgress(projectDir, changeName, progress)
|
|
483
|
+
|
|
484
|
+
// Try --wait on another step (should be refused)
|
|
485
|
+
const output = run(`node "${binCLI}" --dir "${projectDir}" run brainstorm --wait --reason "新问题" --output "新等待" --change ${changeName}`)
|
|
486
|
+
assert(output.includes('已有步骤处于等待状态') || output.includes('等待状态'), '已有 waiting 步骤时 --wait 被拒绝')
|
|
487
|
+
|
|
488
|
+
cleanup(projectDir)
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// ── Summary ──
|
|
492
|
+
console.log(`\n${'='.repeat(50)}`)
|
|
493
|
+
if (failed === 0) {
|
|
494
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
495
|
+
console.log(`${'='.repeat(50)}`)
|
|
496
|
+
console.log('\n🎉 wait gates 测试全部通过!')
|
|
497
|
+
} else {
|
|
498
|
+
console.log(`✅ 通过: ${passed} ❌ 失败: ${failed}`)
|
|
499
|
+
console.log(`${'='.repeat(50)}`)
|
|
500
|
+
}
|
|
501
|
+
process.exit(failed > 0 ? 1 : 0)
|
|
@@ -48,6 +48,64 @@ try {
|
|
|
48
48
|
'ordinary .sillyspec docs should remain writable'
|
|
49
49
|
)
|
|
50
50
|
|
|
51
|
+
writeFileSync(join(runtimeDir, 'gate-status.json'), JSON.stringify({
|
|
52
|
+
stage: 'scan',
|
|
53
|
+
changes: [changeName],
|
|
54
|
+
updatedAt: new Date().toISOString(),
|
|
55
|
+
}, null, 2))
|
|
56
|
+
writeFileSync(join(runtimeDir, 'scan-guard.json'), JSON.stringify({
|
|
57
|
+
sourceCommit: 'new-head',
|
|
58
|
+
startedAt: '2026-06-16T10:00:00.000Z',
|
|
59
|
+
forceRescan: false,
|
|
60
|
+
}, null, 2))
|
|
61
|
+
const scanDoc = join(root, '.sillyspec', 'docs', 'app', 'scan', 'ARCHITECTURE.md')
|
|
62
|
+
mkdirSync(join(root, '.sillyspec', 'docs', 'app', 'scan'), { recursive: true })
|
|
63
|
+
writeFileSync(scanDoc, [
|
|
64
|
+
'---',
|
|
65
|
+
'source_commit: old-head',
|
|
66
|
+
'updated_at: 2026-06-16T09:00:00.000Z',
|
|
67
|
+
'---',
|
|
68
|
+
'# Architecture',
|
|
69
|
+
'',
|
|
70
|
+
].join('\n'))
|
|
71
|
+
assert.equal(
|
|
72
|
+
shouldBlock({ tool: 'Write', filePath: scanDoc, cwd: root }).blocked,
|
|
73
|
+
true,
|
|
74
|
+
'scan overwrite should block stale source_commit without --force-rescan'
|
|
75
|
+
)
|
|
76
|
+
writeFileSync(join(runtimeDir, 'scan-guard.json'), JSON.stringify({
|
|
77
|
+
sourceCommit: 'new-head',
|
|
78
|
+
startedAt: '2026-06-16T10:00:00.000Z',
|
|
79
|
+
forceRescan: true,
|
|
80
|
+
}, null, 2))
|
|
81
|
+
assert.equal(
|
|
82
|
+
shouldBlock({ tool: 'Write', filePath: scanDoc, cwd: root }).blocked,
|
|
83
|
+
false,
|
|
84
|
+
'scan overwrite should allow stale source_commit with --force-rescan'
|
|
85
|
+
)
|
|
86
|
+
const externalSpec = join(root, 'external-spec')
|
|
87
|
+
const externalScanDoc = join(externalSpec, 'docs', 'app', 'scan', 'ARCHITECTURE.md')
|
|
88
|
+
mkdirSync(join(externalSpec, '.runtime'), { recursive: true })
|
|
89
|
+
mkdirSync(join(externalSpec, 'docs', 'app', 'scan'), { recursive: true })
|
|
90
|
+
writeFileSync(join(externalSpec, '.runtime', 'scan-guard.json'), JSON.stringify({
|
|
91
|
+
sourceCommit: 'external-new-head',
|
|
92
|
+
startedAt: '2026-06-16T10:00:00.000Z',
|
|
93
|
+
forceRescan: false,
|
|
94
|
+
}, null, 2))
|
|
95
|
+
writeFileSync(externalScanDoc, [
|
|
96
|
+
'---',
|
|
97
|
+
'source_commit: external-old-head',
|
|
98
|
+
'updated_at: 2026-06-16T09:00:00.000Z',
|
|
99
|
+
'---',
|
|
100
|
+
'# Architecture',
|
|
101
|
+
'',
|
|
102
|
+
].join('\n'))
|
|
103
|
+
assert.equal(
|
|
104
|
+
shouldBlock({ tool: 'Write', filePath: externalScanDoc, cwd: root }).blocked,
|
|
105
|
+
true,
|
|
106
|
+
'external specRoot scan overwrite should read specRoot/.runtime/scan-guard.json'
|
|
107
|
+
)
|
|
108
|
+
|
|
51
109
|
rmSync(join(runtimeDir, 'gate-status.json'), { force: true })
|
|
52
110
|
writeFileSync(join(root, '.sillyspec', 'local.yaml'), [
|
|
53
111
|
'worktreeHook:',
|