sillyspec 3.16.0 → 3.16.2

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/run.js CHANGED
@@ -8,6 +8,7 @@ import { basename, join } from 'path'
8
8
  import { existsSync, readdirSync, mkdirSync, writeFileSync, appendFileSync, readFileSync, statSync } from 'fs'
9
9
  import { ProgressManager } from './progress.js'
10
10
  import { stageRegistry, auxiliaryStages } from './stages/index.js'
11
+ import { checkTransition, runValidators } from './stage-contract.js'
11
12
  import { buildExecuteSteps } from './stages/execute.js'
12
13
  import { buildPlanSteps } from './stages/plan.js'
13
14
  import { formatExecuteSummary } from './worktree-apply.js'
@@ -15,10 +16,122 @@ import { formatExecuteSummary } from './worktree-apply.js'
15
16
  /**
16
17
  * 同步触发辅助函数:_write 后 best-effort 同步到平台
17
18
  */
19
+ /**
20
+ * quick 完成审计:对比 baseline 与实际变更
21
+ */
22
+ async function auditQuickCompletion(cwd, guard, options = {}) {
23
+ const { baselineFiles, allowedFiles = [], allowNew = false, forceBaseline = false } = guard
24
+ const { isConfirm } = options
25
+ const result = { status: 'safe', reasons: [], changedFiles: [], newFiles: [], deletedFiles: [], baselineHit: [] }
26
+
27
+ try {
28
+ const { execSync } = await import('child_process')
29
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
30
+ const currentEntries = gitStatus.trim().split('\n').filter(Boolean)
31
+
32
+ const DANGEROUS_PATTERNS = [
33
+ '.sillyspec/',
34
+ 'package.json',
35
+ 'package-lock.json',
36
+ 'yarn.lock',
37
+ 'pnpm-lock.yaml',
38
+ '.eslintrc',
39
+ 'tsconfig.json',
40
+ ]
41
+
42
+ for (const entry of currentEntries) {
43
+ const status = entry.slice(0, 2).trim()
44
+ const file = entry.slice(3).trim()
45
+ if (!file || file.startsWith('??. ')) continue
46
+
47
+ result.changedFiles.push(file)
48
+ if (status === 'D' || status === ' D') result.deletedFiles.push(file)
49
+ if (status === '??') result.newFiles.push(file)
50
+
51
+ // 检查是否命中 baseline protected files
52
+ if (baselineFiles.includes(file)) {
53
+ result.baselineHit.push(file)
54
+ }
55
+
56
+ // 检查危险文件(除非 force-baseline)
57
+ if (DANGEROUS_PATTERNS.some(p => file.includes(p)) && !forceBaseline) {
58
+ result.reasons.push(`危险文件变更: ${file}`)
59
+ }
60
+ }
61
+
62
+ // 检查 deleted files
63
+ for (const f of result.deletedFiles) {
64
+ result.reasons.push(`删除文件: ${f}`)
65
+ }
66
+
67
+ // 检查 baseline hit(除非 force-baseline)
68
+ if (!forceBaseline) {
69
+ for (const f of result.baselineHit) {
70
+ result.reasons.push(`覆盖 baseline 文件: ${f}`)
71
+ }
72
+ }
73
+
74
+ // 检查 new files(除非 allow-new)
75
+ if (!allowNew) {
76
+ for (const f of result.newFiles) {
77
+ if (!f.startsWith('.sillyspec/quicklog/') && !f.startsWith('.sillyspec/.runtime/')) {
78
+ result.reasons.push(`新增文件(需 --allow-new): ${f}`)
79
+ }
80
+ }
81
+ }
82
+
83
+ // 检查 allowedFiles 范围
84
+ if (allowedFiles.length > 0) {
85
+ for (const f of result.changedFiles) {
86
+ if (!allowedFiles.includes(f) && !f.startsWith('.sillyspec/')) {
87
+ result.reasons.push(`超出 allowedFiles: ${f}`)
88
+ }
89
+ }
90
+ }
91
+
92
+ // 判定结果
93
+ if (result.baselineHit.length > 0 || result.deletedFiles.length > 0 || result.reasons.some(r => r.startsWith('危险') || r.startsWith('删除'))) {
94
+ result.status = 'blocked'
95
+ } else if (result.newFiles.length > 0 || (allowedFiles.length > 0 && result.reasons.some(r => r.startsWith('超出')))) {
96
+ result.status = 'warning'
97
+ }
98
+
99
+ // --confirm 模式:展示 diff 并等待确认
100
+ if (isConfirm && (result.status === 'warning' || result.status === 'blocked')) {
101
+ console.log(`\n📋 quick 变更概览:`)
102
+ console.log(` 新增: ${result.newFiles.length}, 修改: ${result.changedFiles.length - result.newFiles.length - result.deletedFiles.length}, 删除: ${result.deletedFiles.length}`)
103
+ if (result.changedFiles.length > 0) {
104
+ console.log(`\n 变更文件:`)
105
+ for (const f of result.changedFiles) {
106
+ const isBaseline = baselineFiles.includes(f)
107
+ const isDangerous = DANGEROUS_PATTERNS.some(p => f.includes(p))
108
+ const marker = isBaseline ? '🔴' : isDangerous ? '⚠️' : ' '
109
+ console.log(` ${marker} ${f}`)
110
+ }
111
+ }
112
+ console.log(`\n 状态: ${result.status.toUpperCase()}`)
113
+ if (result.reasons.length > 0) {
114
+ console.log(` 原因:`)
115
+ for (const r of result.reasons) {
116
+ console.log(` - ${r}`)
117
+ }
118
+ }
119
+ console.log(`\n 如确认接受这些变更,重新运行:sillyspec run quick --done --confirm --output "..."`)
120
+ console.log(` 或使用 --force-baseline 允许覆盖 baseline 文件,--allow-new 允许新增文件`)
121
+ }
122
+ } catch (e) {
123
+ result.reasons.push(`审计失败: ${e.message}`)
124
+ result.status = 'warning'
125
+ }
126
+
127
+ return result
128
+ }
129
+
18
130
  async function triggerSync(cwd, changeName) {
19
131
  try {
132
+ if (changeName && !existsSync(join(cwd, '.sillyspec', 'changes', changeName))) return
20
133
  const syncMod = await import('./sync.js')
21
- await syncMod.sync(cwd, changeName)
134
+ await syncMod.sync(changeName, cwd)
22
135
  } catch (e) {
23
136
  // sync.js 不存在或同步失败,静默跳过
24
137
  console.warn('⚠️ 同步失败:', e.message)
@@ -32,7 +145,7 @@ async function triggerSync(cwd, changeName) {
32
145
  async function checkApproval(cwd, changeName) {
33
146
  try {
34
147
  const syncMod = await import('./sync.js')
35
- return await syncMod.checkApproval(cwd, changeName)
148
+ return await syncMod.checkApproval(changeName, cwd)
36
149
  } catch (e) {
37
150
  // sync.js 不存在或检查失败,静默跳过
38
151
  return null
@@ -314,23 +427,38 @@ export async function runCommand(args, cwd) {
314
427
  scanRunId: getFlagValue('--scan-run-id'),
315
428
  }
316
429
 
317
- // 跨 --done 生命周期:优先从 metadata 文件恢复 platformOpts
318
- // 首次 scan 时写入,后续 --done 读取
430
+ // 跨 --done 生命周期:从 metadata 文件恢复 platformOpts
431
+ // 首次 scan 时写入,所有后续调用(包括 run、--done、--skip)都读取
319
432
  const platformOptsFile = join(cwd, '.sillyspec', '.runtime', 'platform-scan.json')
320
- if (isDone || isSkip) {
321
- // --done/--skip 阶段:从文件恢复
322
- try {
323
- const { readFileSync } = await import('fs')
324
- const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
325
- if (saved.specRoot) platformOpts.specRoot = saved.specRoot
326
- if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
327
- if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
328
- if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
329
- } catch {
330
- // 文件不存在,说明不是平台模式,跳过
433
+ let platformFileExists = existsSync(platformOptsFile)
434
+ // 如果命令行没传 spec-root,尝试从持久化文件恢复
435
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
436
+ if (platformFileExists) {
437
+ try {
438
+ const { readFileSync } = await import('fs')
439
+ const saved = JSON.parse(readFileSync(platformOptsFile, 'utf8'))
440
+ if (saved.specRoot) platformOpts.specRoot = saved.specRoot
441
+ if (saved.runtimeRoot) platformOpts.runtimeRoot = saved.runtimeRoot
442
+ if (saved.workspaceId) platformOpts.workspaceId = saved.workspaceId
443
+ if (saved.scanRunId) platformOpts.scanRunId = saved.scanRunId
444
+ // 平台模式 fail-fast:文件存在但缺少 specRoot
445
+ if (!platformOpts.specRoot && !platformOpts.runtimeRoot) {
446
+ console.error(`❌ 平台模式参数文件存在但缺少 specRoot/runtimeRoot: ${platformOptsFile}`)
447
+ console.error(' 可能原因:platform-scan.json 损坏或写入不完整')
448
+ console.error(' 解决:重新运行首次 scan 并传入 --spec-root')
449
+ process.exit(1)
450
+ }
451
+ } catch (e) {
452
+ console.error(`❌ 平台模式参数文件读取失败: ${platformOptsFile}`)
453
+ console.error(` 错误: ${e.message}`)
454
+ console.error(' 可能原因:文件损坏')
455
+ console.error(' 解决:删除该文件并重新运行首次 scan 传入 --spec-root')
456
+ process.exit(1)
457
+ }
331
458
  }
332
- } else if (platformOpts.specRoot || platformOpts.runtimeRoot) {
333
- // 首次 scan:持久化 platformOpts
459
+ }
460
+ // 持久化 platformOpts(命令行传入或已恢复的都持久化)
461
+ if (platformOpts.specRoot || platformOpts.runtimeRoot) {
334
462
  try {
335
463
  const { mkdirSync, writeFileSync } = await import('fs')
336
464
  mkdirSync(join(cwd, '.sillyspec', '.runtime'), { recursive: true })
@@ -367,11 +495,22 @@ export async function runCommand(args, cwd) {
367
495
  changeName = flags[changeIdx + 1]
368
496
  }
369
497
 
498
+ // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
499
+ let quickFiles = []
500
+ const filesIdx = flags.indexOf('--files')
501
+ if (filesIdx !== -1 && flags[filesIdx + 1]) {
502
+ quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
503
+ }
504
+
505
+ const isAllowNew = flags.includes('--allow-new')
506
+ const isForceBaseline = flags.includes('--force-baseline')
507
+
370
508
  // 未知参数 fail-fast
371
509
  const knownFlags = new Set([
372
510
  '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
373
511
  '--output', '--input', '--change',
374
512
  '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
513
+ '--files', '--allow-new', '--force-baseline',
375
514
  '--json', '--dir', '--help',
376
515
  ])
377
516
  for (let i = 0; i < flags.length; i++) {
@@ -482,6 +621,18 @@ function resolveChangeNameAuto(cwd) {
482
621
  }
483
622
 
484
623
  async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
624
+ // 状态转换校验
625
+ const prevStage = progress.currentStage || ''
626
+ const transition = checkTransition(prevStage, stageName)
627
+ if (!transition.allowed) {
628
+ console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
629
+ console.error(` 原因: ${transition.reason}`)
630
+ console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
631
+ if (!skipApproval) {
632
+ process.exit(1)
633
+ }
634
+ }
635
+
485
636
  // execute 阶段启动前检查审批
486
637
  if (stageName === 'execute' && !skipApproval) {
487
638
  const approval = await checkApproval(cwd, changeName)
@@ -536,6 +687,39 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
536
687
  console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
537
688
  }
538
689
 
690
+ // quick 阶段:记录 baselineFiles
691
+ if (stageName === 'quick' && !progress.quickGuard) {
692
+ try {
693
+ const { execSync } = await import('child_process')
694
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
695
+ const baselineFiles = gitStatus
696
+ .trim().split('\n').filter(Boolean)
697
+ .map(line => line.slice(3).trim())
698
+ .filter(f => !f.startsWith('.sillyspec/'))
699
+ const allowedFiles = quickFiles || []
700
+ const allowNew = isAllowNew || false
701
+ const forceBaseline = isForceBaseline || false
702
+ progress.quickGuard = {
703
+ baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
704
+ baselineFiles,
705
+ allowedFiles,
706
+ allowNew,
707
+ forceBaseline,
708
+ startedAt: new Date().toISOString(),
709
+ }
710
+ // 写入 quick-guard.json 供 worktree-guard hook 读取
711
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
712
+ writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
713
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
714
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
715
+ if (allowNew) parts.push('允许新增文件')
716
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
717
+ await pm._write(cwd, progress, changeName)
718
+ } catch (e) {
719
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
720
+ }
721
+ }
722
+
539
723
  if (currentIdx > 0) {
540
724
  const completed = currentIdx
541
725
  const total = steps.length
@@ -627,6 +811,39 @@ function validateFileLocations(cwd, stageName, progress, changeName) {
627
811
  }
628
812
  }
629
813
 
814
+ async function archiveChangeDirectory(pm, cwd, progress) {
815
+ const { renameSync } = await import('fs')
816
+ const archiveChangeName = progress.currentChange
817
+ if (!archiveChangeName) {
818
+ console.error('❌ 归档失败:未找到当前变更名(currentChange)')
819
+ process.exit(1)
820
+ }
821
+ const changesDir = join(cwd, '.sillyspec', 'changes')
822
+ const archiveDir = join(changesDir, 'archive')
823
+ const srcDir = join(changesDir, archiveChangeName)
824
+ const date = new Date().toISOString().slice(0, 10)
825
+ const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
826
+
827
+ if (!existsSync(srcDir)) {
828
+ console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
829
+ process.exit(1)
830
+ }
831
+ if (existsSync(destDir)) {
832
+ console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
833
+ process.exit(1)
834
+ }
835
+ mkdirSync(archiveDir, { recursive: true })
836
+ renameSync(srcDir, destDir)
837
+
838
+ if (!existsSync(destDir) || existsSync(srcDir)) {
839
+ console.error('❌ 归档校验失败:移动操作异常')
840
+ process.exit(1)
841
+ }
842
+
843
+ await pm.unregisterChange(cwd, archiveChangeName)
844
+ console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
845
+ }
846
+
630
847
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
631
848
  const { printNext = true, confirm = false, changeName, platformOpts = {} } = options
632
849
  const stageData = progress.stages[stageName]
@@ -661,6 +878,36 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
661
878
  }
662
879
  }
663
880
 
881
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
882
+ if (!confirm) {
883
+ steps[currentIdx].status = 'pending'
884
+ steps[currentIdx].completedAt = null
885
+ if (outputText) steps[currentIdx].output = null
886
+ await pm._write(cwd, progress, changeName)
887
+ console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
888
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
889
+ }
890
+ await archiveChangeDirectory(pm, cwd, progress)
891
+ }
892
+
893
+ // archive "确认归档" 步骤完成后,校验归档完整性
894
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
895
+ const projectName = progress.project || basename(cwd)
896
+ const contractResult = runValidators('archive', cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
897
+ if (contractResult.errors.length > 0) {
898
+ console.error(`\n❌ 归档校验失败:`)
899
+ for (const err of contractResult.errors) {
900
+ console.error(` - ${err}`)
901
+ }
902
+ }
903
+ if (contractResult.warnings.length > 0) {
904
+ console.warn(`\n⚠️ 归档校验警告:`)
905
+ for (const w of contractResult.warnings) {
906
+ console.warn(` - ${w}`)
907
+ }
908
+ }
909
+ }
910
+
664
911
  // plan 阶段 "展开任务" 完成后,动态插入任务蓝图协调器步骤
665
912
  if (stageName === 'plan' && steps[currentIdx]?.name === '展开任务并分组') {
666
913
  const changeDir = resolveChangeDir(cwd, progress)
@@ -842,45 +1089,6 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
842
1089
  // 验证关键文件是否在正确的变更目录下
843
1090
  validateFileLocations(cwd, stageName, progress, changeName)
844
1091
 
845
- // archive 阶段确认归档
846
- if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
847
- if (confirm) {
848
- const { renameSync } = await import('fs')
849
- const archiveChangeName = progress.currentChange
850
- if (!archiveChangeName) {
851
- console.error('❌ 归档失败:未找到当前变更名(currentChange)')
852
- process.exit(1)
853
- }
854
- const changesDir = join(cwd, '.sillyspec', 'changes')
855
- const archiveDir = join(changesDir, 'archive')
856
- const srcDir = join(changesDir, archiveChangeName)
857
- const date = new Date().toISOString().slice(0, 10)
858
- const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
859
-
860
- if (!existsSync(srcDir)) {
861
- console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
862
- process.exit(1)
863
- }
864
- if (existsSync(destDir)) {
865
- console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
866
- process.exit(1)
867
- }
868
- mkdirSync(archiveDir, { recursive: true })
869
- renameSync(srcDir, destDir)
870
-
871
- if (!existsSync(destDir) || existsSync(srcDir)) {
872
- console.error('❌ 归档校验失败:移动操作异常')
873
- process.exit(1)
874
- }
875
-
876
- // 从全局活跃列表移除
877
- await pm.unregisterChange(cwd, archiveChangeName)
878
- console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
879
- } else {
880
- console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
881
- }
882
- }
883
-
884
1092
  // 辅助阶段完成后重置步骤
885
1093
  const stageDef = stageRegistry[stageName]
886
1094
  if (stageDef?.auxiliary) {
@@ -893,6 +1101,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
893
1101
  stageData.steps = freshSteps
894
1102
  stageData.status = 'pending'
895
1103
  stageData.completedAt = null
1104
+ if (progress.currentStage === stageName) progress.currentStage = ''
896
1105
  await pm._write(cwd, progress, changeName)
897
1106
  }
898
1107
 
@@ -925,6 +1134,50 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
925
1134
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
926
1135
  }
927
1136
 
1137
+ // 阶段完成校验
1138
+ const projectName = progress.project || basename(cwd)
1139
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName, specRoot: platformOpts?.specRoot })
1140
+ if (contractResult.errors.length > 0) {
1141
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
1142
+ for (const err of contractResult.errors) {
1143
+ console.error(` - ${err}`)
1144
+ }
1145
+ console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
1146
+ }
1147
+ if (contractResult.warnings.length > 0) {
1148
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
1149
+ for (const w of contractResult.warnings) {
1150
+ console.warn(` - ${w}`)
1151
+ }
1152
+ }
1153
+
1154
+ // quick 阶段完成审计
1155
+ if (stageName === 'quick' && progress.quickGuard) {
1156
+ const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
1157
+ progress.quickGuard.review = review
1158
+ progress.quickGuard.completedAt = new Date().toISOString()
1159
+ // 清理 quick-guard.json
1160
+ try {
1161
+ const { unlinkSync } = await import('fs')
1162
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
1163
+ unlinkSync(guardFile)
1164
+ } catch {}
1165
+ if (review.status === 'blocked') {
1166
+ console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
1167
+ for (const r of review.reasons) {
1168
+ console.error(` - ${r}`)
1169
+ }
1170
+ console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
1171
+ } else if (review.status === 'warning') {
1172
+ console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
1173
+ for (const r of review.reasons) {
1174
+ console.warn(` - ${r}`)
1175
+ }
1176
+ } else {
1177
+ console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
1178
+ }
1179
+ }
1180
+
928
1181
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
929
1182
  await pm._write(cwd, progress, changeName)
930
1183
  triggerSync(cwd, changeName)