sillyspec 3.16.0 → 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/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
@@ -367,11 +480,22 @@ export async function runCommand(args, cwd) {
367
480
  changeName = flags[changeIdx + 1]
368
481
  }
369
482
 
483
+ // 解析 --files a.js,b.js(quick 专用:显式声明 allowedFiles)
484
+ let quickFiles = []
485
+ const filesIdx = flags.indexOf('--files')
486
+ if (filesIdx !== -1 && flags[filesIdx + 1]) {
487
+ quickFiles = flags[filesIdx + 1].split(',').map(f => f.trim()).filter(Boolean)
488
+ }
489
+
490
+ const isAllowNew = flags.includes('--allow-new')
491
+ const isForceBaseline = flags.includes('--force-baseline')
492
+
370
493
  // 未知参数 fail-fast
371
494
  const knownFlags = new Set([
372
495
  '--done', '--skip', '--status', '--reset', '--confirm', '--skip-approval',
373
496
  '--output', '--input', '--change',
374
497
  '--spec-root', '--runtime-root', '--workspace-id', '--scan-run-id',
498
+ '--files', '--allow-new', '--force-baseline',
375
499
  '--json', '--dir', '--help',
376
500
  ])
377
501
  for (let i = 0; i < flags.length; i++) {
@@ -482,6 +606,18 @@ function resolveChangeNameAuto(cwd) {
482
606
  }
483
607
 
484
608
  async function runStage(pm, progress, stageName, cwd, changeName, skipApproval = false, platformOpts = {}) {
609
+ // 状态转换校验
610
+ const prevStage = progress.currentStage || ''
611
+ const transition = checkTransition(prevStage, stageName)
612
+ if (!transition.allowed) {
613
+ console.error(`❌ 阶段转换不允许: ${prevStage || '(起始)'} → ${stageName}`)
614
+ console.error(` 原因: ${transition.reason}`)
615
+ console.error(` 提示: 使用 --skip-approval 绕过(需明确意图)`)
616
+ if (!skipApproval) {
617
+ process.exit(1)
618
+ }
619
+ }
620
+
485
621
  // execute 阶段启动前检查审批
486
622
  if (stageName === 'execute' && !skipApproval) {
487
623
  const approval = await checkApproval(cwd, changeName)
@@ -536,6 +672,39 @@ async function runStage(pm, progress, stageName, cwd, changeName, skipApproval =
536
672
  console.log(`🔄 ${stageName} 阶段已自动重置,重新开始。\n`)
537
673
  }
538
674
 
675
+ // quick 阶段:记录 baselineFiles
676
+ if (stageName === 'quick' && !progress.quickGuard) {
677
+ try {
678
+ const { execSync } = await import('child_process')
679
+ const gitStatus = execSync('git status --porcelain', { cwd, encoding: 'utf8', timeout: 10000 })
680
+ const baselineFiles = gitStatus
681
+ .trim().split('\n').filter(Boolean)
682
+ .map(line => line.slice(3).trim())
683
+ .filter(f => !f.startsWith('.sillyspec/'))
684
+ const allowedFiles = quickFiles || []
685
+ const allowNew = isAllowNew || false
686
+ const forceBaseline = isForceBaseline || false
687
+ progress.quickGuard = {
688
+ baselineCommit: execSync('git rev-parse HEAD', { cwd, encoding: 'utf8', timeout: 5000 }).trim(),
689
+ baselineFiles,
690
+ allowedFiles,
691
+ allowNew,
692
+ forceBaseline,
693
+ startedAt: new Date().toISOString(),
694
+ }
695
+ // 写入 quick-guard.json 供 worktree-guard hook 读取
696
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
697
+ writeFileSync(guardFile, JSON.stringify(progress.quickGuard, null, 2))
698
+ const parts = [`${baselineFiles.length} 个已有脏文件`]
699
+ if (allowedFiles.length > 0) parts.push(`${allowedFiles.length} 个 allowedFiles`)
700
+ if (allowNew) parts.push('允许新增文件')
701
+ console.log(`🛡️ quick 变更边界已记录: ${parts.join(', ')}`)
702
+ await pm._write(cwd, progress, changeName)
703
+ } catch (e) {
704
+ console.warn(`⚠️ baseline 记录失败: ${e.message}`)
705
+ }
706
+ }
707
+
539
708
  if (currentIdx > 0) {
540
709
  const completed = currentIdx
541
710
  const total = steps.length
@@ -627,6 +796,39 @@ function validateFileLocations(cwd, stageName, progress, changeName) {
627
796
  }
628
797
  }
629
798
 
799
+ async function archiveChangeDirectory(pm, cwd, progress) {
800
+ const { renameSync } = await import('fs')
801
+ const archiveChangeName = progress.currentChange
802
+ if (!archiveChangeName) {
803
+ console.error('❌ 归档失败:未找到当前变更名(currentChange)')
804
+ process.exit(1)
805
+ }
806
+ const changesDir = join(cwd, '.sillyspec', 'changes')
807
+ const archiveDir = join(changesDir, 'archive')
808
+ const srcDir = join(changesDir, archiveChangeName)
809
+ const date = new Date().toISOString().slice(0, 10)
810
+ const destDir = join(archiveDir, `${date}-${archiveChangeName}`)
811
+
812
+ if (!existsSync(srcDir)) {
813
+ console.error(`❌ 归档失败:源目录不存在 ${srcDir}`)
814
+ process.exit(1)
815
+ }
816
+ if (existsSync(destDir)) {
817
+ console.error(`❌ 归档失败:目标目录已存在 ${destDir}`)
818
+ process.exit(1)
819
+ }
820
+ mkdirSync(archiveDir, { recursive: true })
821
+ renameSync(srcDir, destDir)
822
+
823
+ if (!existsSync(destDir) || existsSync(srcDir)) {
824
+ console.error('❌ 归档校验失败:移动操作异常')
825
+ process.exit(1)
826
+ }
827
+
828
+ await pm.unregisterChange(cwd, archiveChangeName)
829
+ console.log(`📦 已归档:${archiveChangeName} → archive/${date}-${archiveChangeName}/`)
830
+ }
831
+
630
832
  async function completeStep(pm, progress, stageName, cwd, outputText, inputText = null, options = {}) {
631
833
  const { printNext = true, confirm = false, changeName, platformOpts = {} } = options
632
834
  const stageData = progress.stages[stageName]
@@ -661,6 +863,36 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
661
863
  }
662
864
  }
663
865
 
866
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档') {
867
+ if (!confirm) {
868
+ steps[currentIdx].status = 'pending'
869
+ steps[currentIdx].completedAt = null
870
+ if (outputText) steps[currentIdx].output = null
871
+ await pm._write(cwd, progress, changeName)
872
+ console.log('⚠️ 请添加 --confirm 确认归档,例如:sillyspec run archive --done --confirm --output "确认归档"')
873
+ return { stageCompleted: false, currentIdx, nextPendingIdx: currentIdx }
874
+ }
875
+ await archiveChangeDirectory(pm, cwd, progress)
876
+ }
877
+
878
+ // archive "确认归档" 步骤完成后,校验归档完整性
879
+ if (stageName === 'archive' && steps[currentIdx]?.name === '确认归档' && confirm) {
880
+ const projectName = progress.project || basename(cwd)
881
+ const contractResult = runValidators('archive', cwd, changeName, { projectName })
882
+ if (contractResult.errors.length > 0) {
883
+ console.error(`\n❌ 归档校验失败:`)
884
+ for (const err of contractResult.errors) {
885
+ console.error(` - ${err}`)
886
+ }
887
+ }
888
+ if (contractResult.warnings.length > 0) {
889
+ console.warn(`\n⚠️ 归档校验警告:`)
890
+ for (const w of contractResult.warnings) {
891
+ console.warn(` - ${w}`)
892
+ }
893
+ }
894
+ }
895
+
664
896
  // plan 阶段 "展开任务" 完成后,动态插入任务蓝图协调器步骤
665
897
  if (stageName === 'plan' && steps[currentIdx]?.name === '展开任务并分组') {
666
898
  const changeDir = resolveChangeDir(cwd, progress)
@@ -842,45 +1074,6 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
842
1074
  // 验证关键文件是否在正确的变更目录下
843
1075
  validateFileLocations(cwd, stageName, progress, changeName)
844
1076
 
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
1077
  // 辅助阶段完成后重置步骤
885
1078
  const stageDef = stageRegistry[stageName]
886
1079
  if (stageDef?.auxiliary) {
@@ -893,6 +1086,7 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
893
1086
  stageData.steps = freshSteps
894
1087
  stageData.status = 'pending'
895
1088
  stageData.completedAt = null
1089
+ if (progress.currentStage === stageName) progress.currentStage = ''
896
1090
  await pm._write(cwd, progress, changeName)
897
1091
  }
898
1092
 
@@ -925,6 +1119,50 @@ async function completeStep(pm, progress, stageName, cwd, outputText, inputText
925
1119
  return { stageCompleted: true, currentIdx, nextPendingIdx: -1 }
926
1120
  }
927
1121
 
1122
+ // 阶段完成校验
1123
+ const projectName = progress.project || basename(cwd)
1124
+ const contractResult = runValidators(stageName, cwd, changeName, { projectName })
1125
+ if (contractResult.errors.length > 0) {
1126
+ console.error(`\n❌ 阶段 ${stageName} 校验失败:`)
1127
+ for (const err of contractResult.errors) {
1128
+ console.error(` - ${err}`)
1129
+ }
1130
+ console.error(`\n 提示:修复缺失产物后重新运行此步骤,或使用 --skip-approval 跳过校验`)
1131
+ }
1132
+ if (contractResult.warnings.length > 0) {
1133
+ console.warn(`\n⚠️ 阶段 ${stageName} 校验警告:`)
1134
+ for (const w of contractResult.warnings) {
1135
+ console.warn(` - ${w}`)
1136
+ }
1137
+ }
1138
+
1139
+ // quick 阶段完成审计
1140
+ if (stageName === 'quick' && progress.quickGuard) {
1141
+ const review = await auditQuickCompletion(cwd, progress.quickGuard, { isConfirm })
1142
+ progress.quickGuard.review = review
1143
+ progress.quickGuard.completedAt = new Date().toISOString()
1144
+ // 清理 quick-guard.json
1145
+ try {
1146
+ const { unlinkSync } = await import('fs')
1147
+ const guardFile = join(cwd, '.sillyspec', '.runtime', 'quick-guard.json')
1148
+ unlinkSync(guardFile)
1149
+ } catch {}
1150
+ if (review.status === 'blocked') {
1151
+ console.error(`\n🚫 quick 变更边界审计 — BLOCKED:`)
1152
+ for (const r of review.reasons) {
1153
+ console.error(` - ${r}`)
1154
+ }
1155
+ console.error(`\n 这些文件是 baseline 保护的,不应被修改。`)
1156
+ } else if (review.status === 'warning') {
1157
+ console.warn(`\n⚠️ quick 变更边界审计 — WARNING:`)
1158
+ for (const r of review.reasons) {
1159
+ console.warn(` - ${r}`)
1160
+ }
1161
+ } else {
1162
+ console.log(`\n✅ quick 变更边界审计 — SAFE (变更 ${review.changedFiles.length} 个文件)`)
1163
+ }
1164
+ }
1165
+
928
1166
  progress.lastActive = new Date().toLocaleString('zh-CN',{hour12:false})
929
1167
  await pm._write(cwd, progress, changeName)
930
1168
  triggerSync(cwd, changeName)