sillyspec 3.15.2 → 3.16.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/src/workflow.js CHANGED
@@ -630,8 +630,12 @@ export function generateAllRolePrompts(wf, projectName, context = {}) {
630
630
  * @returns {string|null} 保存路径,失败返回 null
631
631
  */
632
632
  export function saveWorkflowRun(result, options = {}) {
633
- const { cwd = '.', source = 'unknown', stage, step } = options
634
- const runDir = join(cwd, '.sillyspec', '.runtime', 'workflow-runs')
633
+ const { cwd = '.', source = 'unknown', stage, step, runtimeRoot, scanRunId } = options
634
+ // 平台模式:写入 runtime-root/scan-runs/<scan-run-id>/workflow-runs/
635
+ // 本地模式:写入 cwd/.sillyspec/.runtime/workflow-runs/
636
+ const runDir = runtimeRoot
637
+ ? join(runtimeRoot, 'scan-runs', scanRunId || 'unknown', 'workflow-runs')
638
+ : join(cwd, '.sillyspec', '.runtime', 'workflow-runs')
635
639
  try {
636
640
  mkdirSync(runDir, { recursive: true })
637
641
  } catch (e) {
@@ -341,10 +341,24 @@ export function formatExecuteSummary({ changeName, stepsCompleted, stepsTotal, a
341
341
  ? `dirty (${baselineCount} baseline file${baselineCount === 1 ? '' : 's'} protected)`
342
342
  : 'clean';
343
343
 
344
+ // Worktree 最终状态
345
+ const mode = meta.mode || 'worktree';
346
+ let worktreeStatus;
347
+ if (mode === 'native-worktree') {
348
+ worktreeStatus = 'kept (external worktree)';
349
+ } else if (mode === 'in-place-fallback') {
350
+ worktreeStatus = 'none (in-place)';
351
+ } else if (!wtExists) {
352
+ worktreeStatus = 'cleaned';
353
+ } else {
354
+ worktreeStatus = 'exists';
355
+ }
356
+
344
357
  lines.push(`Status: COMPLETED`);
345
358
  lines.push(`Steps: ${stepsCompleted} / ${stepsTotal}`);
346
359
  lines.push(`Baseline: ${baselineStatus}`);
347
360
  lines.push(`Apply: ${applyStatus}`);
361
+ lines.push(`Worktree: ${worktreeStatus}`);
348
362
  }
349
363
 
350
364
  // --- Changed files ---
package/src/worktree.js CHANGED
@@ -17,6 +17,45 @@ const WORKTREES_REL = '.sillyspec/.runtime/worktrees';
17
17
  const BRANCH_PREFIX = 'sillyspec/';
18
18
  const META_FILE = 'meta.json';
19
19
 
20
+ /**
21
+ * 检测当前目录的隔离状态
22
+ * 返回 { inWorktree: boolean, inSubmodule: boolean }
23
+ *
24
+ * 用 git rev-parse --git-dir 和 --git-common-dir 判断:
25
+ * - GIT_DIR != GIT_COMMON 通常是 linked worktree
26
+ * - 但在 git submodule 里也会出现这种情况
27
+ * - 所以必须额外检查 --show-superproject-working-tree 排除 submodule
28
+ */
29
+ export function detectIsolation(cwd = process.cwd()) {
30
+ try {
31
+ const gitDir = execSync('git rev-parse --git-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
32
+ const gitCommonDir = execSync('git rev-parse --git-common-dir', { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] }).trim();
33
+ const superProject = gitQuiet(cwd, 'rev-parse --show-superproject-working-tree');
34
+
35
+ const inWorktree = gitDir !== gitCommonDir && !superProject;
36
+ const inSubmodule = !!superProject;
37
+
38
+ return { inWorktree, inSubmodule, gitDir, gitCommonDir };
39
+ } catch {
40
+ return { inWorktree: false, inSubmodule: false, gitDir: null, gitCommonDir: null };
41
+ }
42
+ }
43
+
44
+ /**
45
+ * 检查 worktree 存储目录是否被 .gitignore 忽略
46
+ * @param {string} cwd - 项目根目录
47
+ * @returns {{ ignored: boolean, path: string }}
48
+ */
49
+ export function checkWorktreeDirIgnored(cwd = process.cwd()) {
50
+ const relPath = WORKTREES_REL;
51
+ try {
52
+ execSync(`git check-ignore -q ${relPath}`, { cwd, encoding: 'utf8', stdio: ['pipe','pipe','pipe'] });
53
+ return { ignored: true, path: relPath };
54
+ } catch {
55
+ return { ignored: false, path: relPath };
56
+ }
57
+ }
58
+
20
59
  function git(cwd, args) {
21
60
  return execSync(`git ${args}`, { cwd, encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
22
61
  }
@@ -115,7 +154,37 @@ export class WorktreeManager {
115
154
  const worktreePath = this.getWorktreePath(name);
116
155
  const branch = BRANCH_PREFIX + name;
117
156
 
118
- // 1. 检查 worktree 是否已存在
157
+ // 0. 检测当前环境隔离状态(submodule guard)
158
+ const isolation = detectIsolation(this.cwd);
159
+ if (isolation.inSubmodule) {
160
+ throw new Error(
161
+ '当前目录在 git submodule 内,SillySpec worktree 不支持在 submodule 中创建。' +
162
+ '\n请在主仓库中执行,或使用 --no-worktree 跳过隔离。'
163
+ );
164
+ }
165
+ if (isolation.inWorktree) {
166
+ // 已在 linked worktree 中,复用当前目录作为 worktree 路径
167
+ console.log(`ℹ️ 已在 linked worktree 中(git-dir: ${isolation.gitDir}),复用当前隔离环境。`);
168
+ return this._createInPlaceMeta(name, {
169
+ worktreePath: this.cwd,
170
+ branch: gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || 'detached',
171
+ mode: 'native-worktree',
172
+ base,
173
+ });
174
+ }
175
+
176
+ // 1. 检查 worktree 目录是否被 gitignore
177
+ const ignoreStatus = checkWorktreeDirIgnored(this.cwd);
178
+ if (!ignoreStatus.ignored) {
179
+ throw new Error(
180
+ `worktree 存储目录 ${ignoreStatus.path} 未被 .gitignore 忽略,` +
181
+ `创建 worktree 可能导致内容被误提交。\n` +
182
+ `请先在 .gitignore 中添加: ${ignoreStatus.path}/\n` +
183
+ `或运行 sillyspec doctor 检查修复。`
184
+ );
185
+ }
186
+
187
+ // 2. 检查 worktree 是否已存在
119
188
  if (existsSync(worktreePath)) {
120
189
  // 目录在但 meta.json 不存在(幽灵状态),自动清理
121
190
  if (!this.getMeta(name)) {
@@ -147,7 +216,7 @@ export class WorktreeManager {
147
216
  mkdirSync(this.worktreeBase, { recursive: true });
148
217
  }
149
218
 
150
- // 5. 创建 worktree(含版本检测)
219
+ // 5. 创建 worktree(含版本检测 + sandbox fallback)
151
220
  try {
152
221
  git(this.cwd, `worktree add ${worktreePath} -b ${branch} ${baseHash}`);
153
222
  } catch (e) {
@@ -155,7 +224,16 @@ export class WorktreeManager {
155
224
  if (!check.supported) {
156
225
  throw new Error(`git worktree add 失败: ${e.stderr || e.message}\n\n${check.reason ? `原因: ${check.reason}` : ''}\n建议: 使用 --no-worktree 标志跳过隔离,或升级 git 到 >= 2.15`);
157
226
  }
158
- throw new Error(`git worktree add 失败: ${e.stderr || e.message}`);
227
+ // sandbox/permission fallback: 降级为 in-place + baseline protection
228
+ console.log(`⚠️ git worktree add 失败(可能是沙箱权限限制),降级为 in-place 模式 + baseline protection`);
229
+ console.log(` 原因: ${e.stderr || e.message}`);
230
+ return this._createInPlaceMeta(name, {
231
+ worktreePath: this.cwd,
232
+ branch,
233
+ baseBranch,
234
+ baseHash,
235
+ mode: 'in-place-fallback',
236
+ });
159
237
  }
160
238
 
161
239
  // 5.5 自动同步远程最新代码(防止 worktree 基于过时的 commit)
@@ -206,15 +284,99 @@ export class WorktreeManager {
206
284
  actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
207
285
  createdAt: new Date().toISOString(),
208
286
  worktreePath,
287
+ mode: 'worktree',
209
288
  baselineFiles,
210
289
  baselineCommit,
211
- baselineHash, // 有 dirty baseline 时指向 checkpoint commit
290
+ baselineHash,
212
291
  };
213
292
 
214
293
  const metaPath = join(worktreePath, META_FILE);
215
294
  writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
216
295
 
217
- return { branch, worktreePath, baseHash };
296
+ return { branch, worktreePath, baseHash, mode: meta.mode };
297
+ }
298
+
299
+ /**
300
+ * 创建 in-place 模式的 meta.json(降级路径)
301
+ * 不创建 git worktree,直接在当前目录记录 baseline 并写入 meta
302
+ * @private
303
+ */
304
+ _createInPlaceMeta(name, { worktreePath, branch, baseBranch, baseHash, mode } = {}) {
305
+ // 解析 base
306
+ if (!baseHash) {
307
+ baseBranch = baseBranch || gitQuiet(this.cwd, 'symbolic-ref --short HEAD') || gitQuiet(this.cwd, 'rev-parse HEAD');
308
+ baseHash = git(this.cwd, 'rev-parse HEAD');
309
+ }
310
+
311
+ const baselineResult = this._overlayBaseline(this.cwd, this.cwd);
312
+ const baselineFiles = baselineResult.files;
313
+ const baselineHash = baselineResult.baselineHash;
314
+
315
+ let baselineCommit = null;
316
+ if (baselineFiles.length > 0) {
317
+ baselineCommit = this._createBaselineCheckpoint(this.cwd, name);
318
+ }
319
+
320
+ const meta = {
321
+ changeName: name,
322
+ branch: branch || BRANCH_PREFIX + name,
323
+ baseBranch,
324
+ baseHash,
325
+ actualBaseHash: gitQuiet(worktreePath, 'rev-parse HEAD') || baseHash,
326
+ createdAt: new Date().toISOString(),
327
+ worktreePath,
328
+ mode: mode || 'in-place-fallback',
329
+ baselineFiles,
330
+ baselineCommit,
331
+ baselineHash,
332
+ };
333
+
334
+ // in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
335
+ if (!existsSync(this.worktreeBase)) {
336
+ mkdirSync(this.worktreeBase, { recursive: true });
337
+ }
338
+ const metaPath = join(this.worktreeBase, name, META_FILE);
339
+ const metaDir = join(this.worktreeBase, name);
340
+ if (!existsSync(metaDir)) {
341
+ mkdirSync(metaDir, { recursive: true });
342
+ }
343
+ writeFileSync(metaPath, JSON.stringify(meta, null, 2) + '\n');
344
+
345
+ return { branch: meta.branch, worktreePath, baseHash, mode: meta.mode };
346
+ }
347
+
348
+ /**
349
+ * 构建 isolation 信息对象,用于写入 gate-status.json
350
+ * @param {string} changeName
351
+ * @returns {{ status: string, mode: string, path: string } | null}
352
+ */
353
+ getIsolationInfo(changeName) {
354
+ const meta = this.getMeta(changeName);
355
+ if (!meta) return null;
356
+
357
+ const mode = meta.mode || 'worktree';
358
+ const statusMap = {
359
+ 'worktree': 'verified',
360
+ 'native-worktree': 'verified',
361
+ 'in-place-fallback': 'degraded',
362
+ };
363
+
364
+ return {
365
+ status: statusMap[mode] || 'verified',
366
+ mode,
367
+ path: meta.worktreePath,
368
+ branch: meta.branch,
369
+ };
370
+ }
371
+
372
+ /**
373
+ * 获取 worktree 的运行模式
374
+ * @param {string} changeName
375
+ * @returns {'worktree'|'native-worktree'|'in-place-fallback'|null}
376
+ */
377
+ getMode(changeName) {
378
+ const meta = this.getMeta(changeName);
379
+ return meta?.mode || null;
218
380
  }
219
381
 
220
382
  /**
@@ -239,6 +401,7 @@ export class WorktreeManager {
239
401
  baseBranch: meta.baseBranch,
240
402
  createdAt: meta.createdAt,
241
403
  worktreePath: meta.worktreePath,
404
+ mode: meta.mode || 'worktree',
242
405
  });
243
406
  }
244
407
 
@@ -246,24 +409,43 @@ export class WorktreeManager {
246
409
  }
247
410
 
248
411
  /**
249
- * 清理 worktree(强制删除,不 apply
412
+ * 清理 worktree(仅限 SillySpec 创建的临时 worktree
250
413
  * @param {string} changeName
251
- * @throws {Error} worktree 不存在
414
+ * @param {{ force?: boolean }} opts - force: 跳过 mode 安检(仅用于 worktree 目录本身)
415
+ * @throws {Error} worktree 不存在、不允许删除
416
+ * @returns {{ result: 'cleaned'|'skipped'|'kept', mode: string }}
252
417
  */
253
- cleanup(changeName) {
418
+ cleanup(changeName, { force = false } = {}) {
254
419
  const name = validateChangeName(changeName);
255
420
  const meta = this.getMeta(name);
256
421
  const worktreePath = this.getWorktreePath(name);
257
422
 
258
423
  if (!meta && !existsSync(worktreePath)) {
259
- throw new Error(`worktree not found: ${name}。meta.json 不存在,目录也不存在,可能已被清理或从未创建。`);
424
+ return { result: 'skipped', mode: null };
425
+ }
426
+
427
+ const mode = meta?.mode || 'worktree';
428
+
429
+ // 安全检查:只有 SillySpec 创建的 worktree 才允许删除
430
+ if (!force) {
431
+ if (mode === 'native-worktree') {
432
+ throw new Error(
433
+ `当前 worktree 是外部/原生隔离环境(mode: native-worktree),SillySpec 不允许删除。\n` +
434
+ `此 worktree 不是由 SillySpec 创建的,请手动管理。\n` +
435
+ `如需强制清理,使用 --force 标志。`
436
+ );
437
+ }
438
+ if (mode === 'in-place-fallback') {
439
+ return { result: 'skipped', mode };
440
+ }
260
441
  }
261
442
 
262
443
  // 1. 尝试 git worktree remove
444
+ let gitRemoveOk = true;
263
445
  try {
264
446
  git(this.cwd, `worktree remove ${worktreePath} --force`);
265
- } catch {
266
- // git worktree remove 失败,尝试直接删除目录
447
+ } catch (e) {
448
+ gitRemoveOk = false;
267
449
  }
268
450
  const branch = (meta && meta.branch) || BRANCH_PREFIX + name;
269
451
 
@@ -283,6 +465,14 @@ export class WorktreeManager {
283
465
  if (existsSync(worktreePath)) {
284
466
  rmSync(worktreePath, { recursive: true, force: true });
285
467
  }
468
+
469
+ // 5. 清除 meta 目录(如果 worktree 目录在 worktreeBase 下)
470
+ const metaDir = join(this.worktreeBase, name);
471
+ if (existsSync(metaDir)) {
472
+ rmSync(metaDir, { recursive: true, force: true });
473
+ }
474
+
475
+ return { result: gitRemoveOk ? 'cleaned' : 'force-cleaned', mode };
286
476
  }
287
477
 
288
478
  /**
@@ -0,0 +1,26 @@
1
+ import { execFileSync } from 'node:child_process'
2
+ import { readdirSync, statSync } from 'node:fs'
3
+ import { join } from 'node:path'
4
+
5
+ const roots = ['src']
6
+ const files = []
7
+
8
+ function walk(dir) {
9
+ for (const entry of readdirSync(dir)) {
10
+ const full = join(dir, entry)
11
+ const st = statSync(full)
12
+ if (st.isDirectory()) {
13
+ walk(full)
14
+ continue
15
+ }
16
+ if (/\.(js|cjs|mjs)$/.test(entry)) files.push(full)
17
+ }
18
+ }
19
+
20
+ for (const root of roots) walk(root)
21
+
22
+ for (const file of files.sort()) {
23
+ execFileSync(process.execPath, ['--check', file], { stdio: 'inherit' })
24
+ }
25
+
26
+ console.log(`Checked ${files.length} JavaScript files`)
@@ -0,0 +1,20 @@
1
+ import { readdirSync } from 'node:fs'
2
+ import { dirname, join } from 'node:path'
3
+ import { fileURLToPath, pathToFileURL } from 'node:url'
4
+
5
+ const testDir = dirname(fileURLToPath(import.meta.url))
6
+ const files = readdirSync(testDir)
7
+ .filter(file => file.endsWith('.test.mjs'))
8
+ .sort()
9
+
10
+ if (files.length === 0) {
11
+ console.log('No test files found')
12
+ process.exit(0)
13
+ }
14
+
15
+ for (const file of files) {
16
+ console.log(`\nRunning ${file}`)
17
+ await import(pathToFileURL(join(testDir, file)).href)
18
+ }
19
+
20
+ console.log(`\nAll ${files.length} test file(s) passed`)
@@ -0,0 +1,68 @@
1
+ /**
2
+ * 防回归测试:scan.js 中不允许硬编码 .sillyspec/docs/<project>/ 作为写入路径
3
+ * 所有正式文档路径必须使用 {DOCS_ROOT} 占位符
4
+ */
5
+ import { readFileSync } from 'fs'
6
+ import { join, dirname } from 'path'
7
+ import { fileURLToPath } from 'url'
8
+
9
+ const __dirname = dirname(fileURLToPath(import.meta.url))
10
+ const scanPath = join(__dirname, '..', 'src', 'stages', 'scan.js')
11
+ const content = readFileSync(scanPath, 'utf8')
12
+
13
+ const banned = [
14
+ '.sillyspec/docs/<project>/scan/',
15
+ '.sillyspec/docs/<project>/modules/',
16
+ '.sillyspec/docs/<project>/flows/',
17
+ '.sillyspec/docs/<project>/glossary.md',
18
+ ]
19
+
20
+ const required = [
21
+ '{DOCS_ROOT}/scan/',
22
+ '{DOCS_ROOT}/modules/',
23
+ '{DOCS_ROOT}/flows/',
24
+ ]
25
+
26
+ let failed = false
27
+
28
+ // 禁止硬编码路径
29
+ for (const pattern of banned) {
30
+ if (content.includes(pattern)) {
31
+ console.error(`❌ FAIL: scan.js 仍包含硬编码路径 "${pattern}"`)
32
+ failed = true
33
+ } else {
34
+ console.log(`✅ PASS: 不包含 "${pattern}"`)
35
+ }
36
+ }
37
+
38
+ // 必须包含占位符
39
+ for (const pattern of required) {
40
+ if (content.includes(pattern)) {
41
+ console.log(`✅ PASS: 包含占位符 "${pattern}"`)
42
+ } else {
43
+ console.error(`❌ FAIL: scan.js 缺少占位符 "${pattern}"`)
44
+ failed = true
45
+ }
46
+ }
47
+
48
+ // 禁止硬编码 projects 路径
49
+ if (content.includes('.sillyspec/projects/')) {
50
+ console.error('❌ FAIL: scan.js 仍包含硬编码 ".sillyspec/projects/"')
51
+ failed = true
52
+ } else {
53
+ console.log('✅ PASS: 不包含 ".sillyspec/projects/"')
54
+ }
55
+
56
+ if (content.includes('{PROJECTS_ROOT}/')) {
57
+ console.log('✅ PASS: 包含占位符 "{PROJECTS_ROOT}/"')
58
+ } else {
59
+ console.error('❌ FAIL: scan.js 缺少占位符 "{PROJECTS_ROOT}/"')
60
+ failed = true
61
+ }
62
+
63
+ if (failed) {
64
+ console.error('\n💥 有测试失败!scan.js 路径占位符可能被回退为硬编码。')
65
+ process.exit(1)
66
+ } else {
67
+ console.log('\n✅ 全部通过 — scan.js 路径占位符防回归测试 OK')
68
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * StageContract 状态转换 + validator 测试
3
+ */
4
+ import { checkTransition, runValidators, getContract } from '../src/stage-contract.js'
5
+
6
+ let failed = 0
7
+
8
+ // === 状态转换测试 ===
9
+ const transitionTests = [
10
+ // [from, to, expectedAllowed]
11
+ // 主流程正常顺序
12
+ ['', 'brainstorm', true],
13
+ ['brainstorm', 'plan', true],
14
+ ['plan', 'execute', true],
15
+ ['execute', 'verify', true],
16
+ ['verify', 'archive', true],
17
+
18
+ // 跳步应被拦截
19
+ ['', 'plan', false],
20
+ ['', 'execute', false],
21
+ ['brainstorm', 'execute', false],
22
+ ['plan', 'verify', false],
23
+ ['execute', 'archive', false],
24
+
25
+ // 回退应被拦截
26
+ ['plan', 'brainstorm', false],
27
+ ['execute', 'plan', false],
28
+ ['verify', 'execute', false],
29
+
30
+ // 辅助阶段随时可执行
31
+ ['', 'scan', true],
32
+ ['', 'quick', true],
33
+ ['', 'explore', true],
34
+ ['', 'doctor', true],
35
+ ['', 'archive', true],
36
+ ['brainstorm', 'scan', true],
37
+ ['plan', 'quick', true],
38
+ ['execute', 'doctor', true],
39
+
40
+ // 从辅助阶段进入主流程允许
41
+ ['scan', 'plan', true],
42
+ ['scan', 'brainstorm', true],
43
+ ['quick', 'plan', true],
44
+ ['doctor', 'brainstorm', true],
45
+
46
+ // archive 特殊:verify 后允许,其他主流程不允许直接跳
47
+ ['verify', 'archive', true],
48
+ ['execute', 'archive', false],
49
+ ['plan', 'archive', false],
50
+ ]
51
+
52
+ console.log('=== 状态转换测试 ===')
53
+ for (const [from, to, expected] of transitionTests) {
54
+ const r = checkTransition(from, to)
55
+ const ok = r.allowed === expected
56
+ if (!ok) failed++
57
+ console.log(ok ? '✅' : '❌', `${from || '(起始)'} → ${to}: allowed=${r.allowed} (exp ${expected})${ok ? '' : ' reason: ' + r.reason}`)
58
+ }
59
+
60
+ // === Validator 测试 ===
61
+ console.log('\n=== Validator 测试 ===')
62
+
63
+ // plan validator:plan.md 不存在应报错
64
+ const planResult = runValidators('plan', '.', 'nonexistent-change')
65
+ if (planResult.ok === false && planResult.errors.length > 0) {
66
+ console.log('✅ plan validator 检测到缺失 plan.md')
67
+ } else {
68
+ console.log('❌ plan validator 未检测到缺失 plan.md')
69
+ failed++
70
+ }
71
+
72
+ // verify validator:变更目录不存在应报错
73
+ const verifyResult = runValidators('verify', '.', 'nonexistent-change')
74
+ if (verifyResult.ok === false && verifyResult.errors.length > 0) {
75
+ console.log('✅ verify validator 检测到缺失变更目录')
76
+ } else {
77
+ console.log('❌ verify validator 未检测到缺失变更目录')
78
+ failed++
79
+ }
80
+
81
+ // scan validator:文档目录不存在应报错
82
+ const scanResult = runValidators('scan', '/tmp/nonexistent-project', 'test', { projectName: 'test' })
83
+ if (scanResult.ok === false && scanResult.errors.length > 0) {
84
+ console.log('✅ scan validator 检测到缺失 scan 文档')
85
+ } else {
86
+ console.log('❌ scan validator 未检测到缺失 scan 文档')
87
+ failed++
88
+ }
89
+
90
+ // 无 validator 的阶段应该 pass
91
+ const brainstormResult = runValidators('brainstorm', '.', 'test')
92
+ if (brainstormResult.ok === true) {
93
+ console.log('✅ brainstorm 无 validator 直接通过')
94
+ } else {
95
+ console.log('❌ brainstorm 无 validator 但失败了')
96
+ failed++
97
+ }
98
+
99
+ // === StageContract 结构测试 ===
100
+ console.log('\n=== Contract 结构测试 ===')
101
+
102
+ const plan = getContract('plan')
103
+ if (plan.allowedFrom.includes('brainstorm') && plan.allowedTo.includes('execute') && plan.validators.length === 1) {
104
+ console.log('✅ plan contract 结构正确')
105
+ } else {
106
+ console.log('❌ plan contract 结构异常:', JSON.stringify(plan))
107
+ failed++
108
+ }
109
+
110
+ const verify = getContract('verify')
111
+ if (verify.allowedFrom.includes('execute') && verify.allowedTo.includes('archive')) {
112
+ console.log('✅ verify contract 结构正确')
113
+ } else {
114
+ console.log('❌ verify contract 结构异常')
115
+ failed++
116
+ }
117
+
118
+ const unknown = getContract('nonexistent')
119
+ if (unknown === null) {
120
+ console.log('✅ 未知阶段返回 null')
121
+ } else {
122
+ console.log('❌ 未知阶段应返回 null')
123
+ failed++
124
+ }
125
+
126
+ // === 结果 ===
127
+ console.log(`\n${failed === 0 ? '✅ 全部通过' : `❌ ${failed} 项失败`}`)
128
+ process.exit(failed > 0 ? 1 : 0)
@@ -0,0 +1,43 @@
1
+ import assert from 'node:assert/strict'
2
+ import { stageRegistry } from '../src/stages/index.js'
3
+ import { buildPlanSteps } from '../src/stages/plan.js'
4
+ import { buildExecuteSteps } from '../src/stages/execute.js'
5
+
6
+ const stageSteps = {
7
+ brainstorm: stageRegistry.brainstorm.steps,
8
+ propose: stageRegistry.propose.steps,
9
+ scan: stageRegistry.scan.steps,
10
+ quick: stageRegistry.quick.steps,
11
+ archive: stageRegistry.archive.steps,
12
+ verify: stageRegistry.verify.steps,
13
+ plan: buildPlanSteps(null),
14
+ execute: buildExecuteSteps(null),
15
+ }
16
+
17
+ function names(stage) {
18
+ return stageSteps[stage].map(step => step.name)
19
+ }
20
+
21
+ function assertContains(stage, expectedNames) {
22
+ const actual = names(stage)
23
+ for (const name of expectedNames) {
24
+ assert.ok(actual.includes(name), `${stage} should include step "${name}". Actual: ${actual.join(', ')}`)
25
+ }
26
+ }
27
+
28
+ assert.equal(stageSteps.brainstorm.length, 11, 'brainstorm should expose design self-review and final confirmation separately')
29
+ assertContains('brainstorm', ['写设计文档并自审', '用户确认并生成规范文件'])
30
+
31
+ assert.equal(stageSteps.propose.length, 7, 'propose should expose generation and self-check separately')
32
+ assertContains('propose', ['生成规范文件', '自检门控', '展示并更新进度'])
33
+
34
+ assert.equal(stageSteps.scan.length, 10, 'scan base definition should stay at 10 steps before per-project expansion')
35
+ assertContains('scan', ['构建扫描项目列表', '生成本地配置', '生成模块映射'])
36
+
37
+ assert.equal(stageSteps.quick.length, 3, 'quick should remain a short auxiliary workflow')
38
+ assertContains('quick', ['理解任务', '实现并验证', '暂存和更新记录'])
39
+
40
+ assert.equal(stageSteps.archive.length, 5, 'archive should keep its five-step lifecycle')
41
+ assertContains('archive', ['extract-module-impact', 'sync-module-docs', '确认归档'])
42
+
43
+ console.log('✅ stage definition regression checks passed')