sillyspec 3.22.9 → 3.23.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/sync.js CHANGED
@@ -75,7 +75,9 @@ function parseSimpleYaml(content) {
75
75
  for (const line of content.split('\n')) {
76
76
  const trimmed = line.trim();
77
77
  if (!trimmed || trimmed.startsWith('#')) continue;
78
- if (!trimmed.startsWith(' ')) {
78
+ // 用原始 line(非 trimmed)判断缩进:trimmed 已去前导空格,startsWith(' ') 恒 false
79
+ // 会导致缩进子段(如 platform 下 url/token)被误判为 root 行、section 恒为空 {}
80
+ if (!line.startsWith(' ')) {
79
81
  const m = trimmed.match(/^(\S+)\s*:\s*(.*)$/);
80
82
  if (m) {
81
83
  const key = m[1];
@@ -413,14 +415,80 @@ export async function checkApproval(changeName, cwd) {
413
415
  return new SyncManager(cwd).checkApproval(changeName);
414
416
  }
415
417
 
418
+ // TBD-hub-api: approve/reject 端点路径与请求体以 SillyHub 仓库实际 API 为准;
419
+ // 对齐时只改本函数(_submitApproval),无需动 approve/reject 入口。
420
+ /**
421
+ * 向平台提交审批决定(approve/reject 共用)。
422
+ *
423
+ * 显式用户/daemon 动作:网络/平台失败必须可见(decisions.md D-006@v1)——
424
+ * 与 best-effort 的 sync 不同,此处失败打 error 并置 process.exitCode = 1。
425
+ *
426
+ * @param {string} cwd - 工作目录
427
+ * @param {string} changeName - 变更名称
428
+ * @param {'approved'|'rejected'} decision - 审批决定
429
+ * @param {string|null} reason - rejected 时的原因(approved 时忽略)
430
+ * @returns {Promise<boolean>} 是否成功
431
+ */
432
+ async function _submitApproval(cwd, changeName, decision, reason = null) {
433
+ const sm = new SyncManager(cwd);
434
+ const platform = sm._getPlatform();
435
+ if (!platform) {
436
+ console.error('❌ 未连接平台,请先 sillyspec platform connect');
437
+ process.exitCode = 1;
438
+ return false;
439
+ }
440
+
441
+ if (!changeName) {
442
+ console.error('❌ 审批需要指定变更名称 (changeName)');
443
+ process.exitCode = 1;
444
+ return false;
445
+ }
446
+
447
+ // TBD-hub-api: 端点路径/body 以 SillyHub 实际 API 为准,对齐时只改此处
448
+ const approvalUrl = `${platform.url}/api/changes/${encodeURIComponent(changeName)}/approval`;
449
+ const body = decision === 'rejected'
450
+ ? { decision: 'rejected', reason }
451
+ : { decision: 'approved' };
452
+
453
+ const result = await fetchJson(approvalUrl, {
454
+ method: 'POST',
455
+ headers: {
456
+ 'Content-Type': 'application/json',
457
+ Authorization: `Bearer ${platform.token}`,
458
+ },
459
+ body: JSON.stringify(body),
460
+ });
461
+
462
+ // fetchJson 在非 2xx / 网络错 / 超时 / 非 JSON 时返回 null
463
+ if (result === null) {
464
+ console.error(`❌ 审批请求失败 (${changeName} → ${decision}),详情见上方 [sync] 警告`);
465
+ process.exitCode = 1;
466
+ return false;
467
+ }
468
+
469
+ // HTTP 成功后更新本地 approvals 表(HTTP 已成功是主要目标,落库失败只 warn 不阻断)
470
+ try {
471
+ const { ProgressManager } = await import('./progress.js');
472
+ const pm = new ProgressManager({ specDir: safePlatformSpecDir(cwd) });
473
+ await pm._updateApprovalStatus(cwd, changeName, decision, reason);
474
+ } catch (err) {
475
+ console.warn(`[sync] 更新本地审批状态失败 (${changeName}): ${err.message}`);
476
+ }
477
+
478
+ if (decision === 'approved') {
479
+ console.log(`✅ 已批准变更 ${changeName}`);
480
+ } else {
481
+ console.log(`✅ 已拒绝变更 ${changeName}${reason ? `(原因: ${reason})` : ''}`);
482
+ }
483
+ return true;
484
+ }
485
+
416
486
  export async function approve(changeName, cwd) {
417
- // TODO: SillyHub 平台侧实现后启用
418
- console.warn(`[sync] approve 尚未实现 (${changeName})`);
487
+ return _submitApproval(cwd, changeName, 'approved');
419
488
  }
420
489
 
421
490
  export async function reject(changeName, reason, cwd) {
422
- // TODO: SillyHub 平台侧实现后启用
423
- console.warn(`[sync] reject 尚未实现 (${changeName})`);
491
+ return _submitApproval(cwd, changeName, 'rejected', reason);
424
492
  }
425
493
 
426
494
  export async function status(cwd) {
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync } from 'fs'
12
12
  import { join, resolve } from 'path'
13
+ import { execFileSync } from 'child_process'
13
14
 
14
15
  // ── review.json schema version ──
15
16
  export const REVIEW_SCHEMA_VERSION = 1
@@ -147,7 +148,7 @@ function isTaskLowRisk(changeDir, taskId) {
147
148
  }
148
149
 
149
150
  export function validateTaskReviews(opts) {
150
- const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null } = opts
151
+ const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null } = opts
151
152
 
152
153
  const taskIds = parseTaskIdsFromPlan(planContent)
153
154
 
@@ -178,7 +179,7 @@ export function validateTaskReviews(opts) {
178
179
  if (isTaskLowRisk(changeDir, taskId)) {
179
180
  warnings.push(`${taskId}: 缺少 review.json — task 声明 low_risk: true,已豁免评审(type-only/机械迁移)`)
180
181
  } else {
181
- errors.push(`${taskId}: 缺少 review.json — task 未经过评审`)
182
+ errors.push(`${taskId}: 缺少 review.json — task 未经过评审(期望路径:${reviewPath},execute run ID: ${executeRunId})`)
182
183
  }
183
184
  }
184
185
  continue
@@ -201,6 +202,24 @@ export function validateTaskReviews(opts) {
201
202
  continue
202
203
  }
203
204
 
205
+ // ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
206
+ if (gitDir) {
207
+ const evidence = verifyReviewGitEvidence(review, gitDir)
208
+ for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
209
+ if (!evidence.ok) {
210
+ for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
211
+ continue
212
+ }
213
+ if (evidence.emptyDiff) {
214
+ if (isTaskLowRisk(changeDir, taskId)) {
215
+ warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
216
+ } else {
217
+ errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
218
+ continue
219
+ }
220
+ }
221
+ }
222
+
204
223
  // 检查 cannot_verify
205
224
  if (review.specVerdict === 'cannot_verify' || review.qualityVerdict === 'cannot_verify') {
206
225
  if (!allowCannotVerify) {
@@ -252,6 +271,90 @@ export function validateTaskReviews(opts) {
252
271
  }
253
272
  }
254
273
 
274
+ // ── Git 真实性交叉校验 ──
275
+
276
+ function runGit(gitDir, args) {
277
+ return execFileSync('git', args, {
278
+ cwd: gitDir,
279
+ encoding: 'utf8',
280
+ timeout: 15000,
281
+ stdio: ['ignore', 'pipe', 'pipe'],
282
+ }).trim()
283
+ }
284
+
285
+ /**
286
+ * 校验 review.json 的 base/head 是否指向真实 git 提交,且 diff 非空。
287
+ *
288
+ * 历史漏洞:base/head 只做非空字符串检查,agent 可以填任意假 hash 批量伪造
289
+ * review.json 通过 Task Review Gate。此函数用 git 做客观交叉校验:
290
+ * 1. base/head 必须是仓库中可解析的真实 commit
291
+ * 2. base..head 的 diff 不能为空(评审一个零改动的"任务"无意义)
292
+ * 3. review.changedFiles(如提供)必须与实际 diff 文件有交集
293
+ *
294
+ * git 环境不可用(非 git 仓库 / git 缺失)时返回 unavailable,由调用方降级为
295
+ * warning——不因环境问题误杀,但记录在输出中。
296
+ *
297
+ * @param {object} review - 已通过 schema 校验的 review 对象
298
+ * @param {string} gitDir - 执行 git 命令的目录(worktree 优先,回退主仓库)
299
+ * @returns {{ ok: boolean, emptyDiff: boolean, errors: string[], warnings: string[], unavailable: boolean }}
300
+ */
301
+ export function verifyReviewGitEvidence(review, gitDir) {
302
+ const errors = []
303
+ const warnings = []
304
+
305
+ // git 环境探测:失败即 unavailable,交由调用方降级
306
+ try {
307
+ runGit(gitDir, ['rev-parse', '--git-dir'])
308
+ } catch (e) {
309
+ return {
310
+ ok: true,
311
+ emptyDiff: false,
312
+ errors: [],
313
+ warnings: [`git 环境不可用(${gitDir}),跳过 review 真实性交叉校验: ${e.message?.split('\n')[0] || e.message}`],
314
+ unavailable: true,
315
+ }
316
+ }
317
+
318
+ for (const field of ['base', 'head']) {
319
+ const hash = review[field]
320
+ try {
321
+ runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
322
+ } catch {
323
+ errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造`)
324
+ }
325
+ }
326
+ if (errors.length > 0) {
327
+ return { ok: false, emptyDiff: false, errors, warnings, unavailable: false }
328
+ }
329
+
330
+ let diffFiles = []
331
+ try {
332
+ const out = runGit(gitDir, ['diff', '--name-only', `${review.base}..${review.head}`])
333
+ diffFiles = out ? out.split('\n').filter(Boolean) : []
334
+ } catch (e) {
335
+ warnings.push(`git diff ${review.base}..${review.head} 执行失败,跳过 diff 校验: ${e.message?.split('\n')[0] || e.message}`)
336
+ return { ok: true, emptyDiff: false, errors, warnings, unavailable: false }
337
+ }
338
+
339
+ const emptyDiff = diffFiles.length === 0
340
+
341
+ // changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关
342
+ if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
343
+ const normalize = (p) => String(p).replace(/^\.\//, '')
344
+ const diffSet = new Set(diffFiles.map(normalize))
345
+ const hasOverlap = review.changedFiles.some(f => {
346
+ const nf = normalize(f)
347
+ // 兼容 review 写相对/部分路径:前后缀匹配即可
348
+ return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
349
+ })
350
+ if (!hasOverlap) {
351
+ errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关`)
352
+ }
353
+ }
354
+
355
+ return { ok: errors.length === 0, emptyDiff, errors, warnings, unavailable: false }
356
+ }
357
+
255
358
  /**
256
359
  * 将 cannot_verify 的 requiredEvidence 写入 change 目录
257
360
  * 供 verify 阶段消费
@@ -344,7 +447,7 @@ export function ensureTaskReviewDir(runtimeRoot, executeRunId, taskId) {
344
447
  * 打印校验结果
345
448
  * @param {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array }} result
346
449
  */
347
- export function printReviewResult(result) {
450
+ export function printReviewResult(result, context = {}) {
348
451
  if (result.ok && result.warnings.length === 0) {
349
452
  console.log('\n✅ Task Review Gate — 所有任务评审通过')
350
453
  return
@@ -355,7 +458,10 @@ export function printReviewResult(result) {
355
458
  for (const err of result.errors) {
356
459
  console.error(` - ${err}`)
357
460
  }
358
- console.error('\n 提示:为缺失/失败的任务补充 review.json,然后重新 --done')
461
+ const hint = context.runtimeRoot && context.executeRunId
462
+ ? `期望路径:${context.runtimeRoot}/execute-runs/${context.executeRunId}/tasks/<task-XX>/review.json`
463
+ : '为缺失/失败的任务补充 review.json'
464
+ console.error(`\n 提示:${hint},然后重新 --done`)
359
465
  }
360
466
 
361
467
  if (result.warnings.length > 0) {
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Verify Postcheck — CLI 客观测试执行与自报告对账
3
+ *
4
+ * verify 阶段历史上完全依赖 agent 自报告(跑没跑测试、结果如何全写在
5
+ * verify-result.md 里),可被文案绕过。此模块让 CLI 在 verify 完成时
6
+ * 亲自执行 local.yaml 配置的测试命令,与 verify-result.md 的结论对账:
7
+ * 自报告 PASS 但实测失败 → 阻断 verify 完成。
8
+ *
9
+ * 未配置 commands.test(或标记 unavailable)时降级为 warning 不阻断,
10
+ * 兼容无测试项目。
11
+ *
12
+ * test_strategy 支持(D-002@v1):
13
+ * - full(默认):整跑 commands.test(brownfield 行为不变)
14
+ * - module:按 local.yaml modules 映射,仅跑 git diff 命中的模块子集
15
+ * 测试,避免 monorepo 全量测试超 gate timeout。
16
+ */
17
+
18
+ import { execSync } from 'child_process'
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
20
+ import { join } from 'path'
21
+
22
+ // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
23
+ const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
24
+ const OUTPUT_TAIL_CHARS = 4000
25
+
26
+ /**
27
+ * 从 local.yaml 文本提取 commands.test。
28
+ * 轻量正则(与 worktree-deps/scan-postcheck 同风格,不引 yaml 依赖):
29
+ * 支持带引号与不带引号两种写法;'unavailable' 视为未配置。
30
+ */
31
+ export function extractTestCommand(yamlText) {
32
+ if (!yamlText) return null
33
+ const doubleQuoted = yamlText.match(/^\s*test:\s*"([^"]+)"\s*(?:#.*)?$/m)
34
+ const singleQuoted = yamlText.match(/^\s*test:\s*'([^']+)'\s*(?:#.*)?$/m)
35
+ const quoted = doubleQuoted || singleQuoted
36
+ if (quoted && quoted[1]) {
37
+ return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
38
+ }
39
+ const bare = yamlText.match(/^\s*test:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
40
+ if (bare && bare[1]) {
41
+ const cmd = bare[1].trim()
42
+ return cmd.toLowerCase() === 'unavailable' ? null : cmd
43
+ }
44
+ return null
45
+ }
46
+
47
+ /**
48
+ * 从 local.yaml 文本提取顶层 test_strategy。
49
+ * 轻量正则(与 extractTestCommand 同风格,不引 yaml 依赖)。
50
+ *
51
+ * @param {string} yamlText
52
+ * @returns {'full'|'module'|null} - 解析到的策略;缺省/无法解析返回 null(调用方按 full 处理)
53
+ */
54
+ export function extractTestStrategy(yamlText) {
55
+ if (!yamlText) return null
56
+ const m = yamlText.match(/^\s*test_strategy:\s*([A-Za-z_]+)\s*(?:#.*)?$/m)
57
+ if (!m || !m[1]) return null
58
+ const v = m[1].trim().toLowerCase()
59
+ if (v === 'module') return 'module'
60
+ if (v === 'full') return 'full'
61
+ return null
62
+ }
63
+
64
+ /**
65
+ * 从 local.yaml 文本解析 modules 映射块。
66
+ * 设计约定(见 change 2026-07-10-tooling-followups design.md #2 方案 A):
67
+ *
68
+ * modules:
69
+ * backend: { path: "backend/", test: "cd backend && uv run pytest" }
70
+ * frontend: { path: "frontend/", test: "cd frontend && pnpm test" }
71
+ *
72
+ * 每个模块一行 inline flow mapping,含 path 与 test 两个键。
73
+ * 轻量行扫描(与 modules.js parseModuleMapSimple 同风格,不引 yaml 依赖)。
74
+ *
75
+ * @param {string} yamlText
76
+ * @returns {Record<string, {path:string, test:string}>|null}
77
+ * - 找不到 modules 块 → null(调用方 fallback commands.test)
78
+ * - 找到块但无有效条目 → null
79
+ */
80
+ export function extractModules(yamlText) {
81
+ if (!yamlText) return null
82
+ const lines = yamlText.split('\n')
83
+
84
+ // 定位 modules: 起始行(必须是顶层 key,行首无缩进或仅注释后顶层)
85
+ let startIdx = -1
86
+ for (let i = 0; i < lines.length; i++) {
87
+ const m = lines[i].match(/^modules:\s*(?:#.*)?$/)
88
+ if (m) { startIdx = i; break }
89
+ }
90
+ if (startIdx === -1) return null
91
+
92
+ const modules = {}
93
+ for (let i = startIdx + 1; i < lines.length; i++) {
94
+ const line = lines[i]
95
+ // 子条目:以 2 空格缩进 + key + ':' 开头
96
+ const entry = line.match(/^ ([A-Za-z0-9_.\-]+):\s*(.*)$/)
97
+ if (!entry) {
98
+ // 遇到新的顶层 key(行首非空格且非注释)→ modules 块结束
99
+ if (line.length > 0 && !line.startsWith(' ') && !line.startsWith('#') && line.trim() !== '') break
100
+ continue
101
+ }
102
+ const name = entry[1]
103
+ const rest = entry[2].trim()
104
+ if (rest === '' || rest.startsWith('#')) continue // 子块展开式(本实现只支持 inline flow)
105
+ // 解析 inline flow mapping: { path: "...", test: "..." }
106
+ const pathVal = parseFlowValue(rest, 'path')
107
+ const testVal = parseFlowValue(rest, 'test')
108
+ if (pathVal && testVal) {
109
+ modules[name] = { path: pathVal, test: testVal }
110
+ }
111
+ }
112
+
113
+ return Object.keys(modules).length > 0 ? modules : null
114
+ }
115
+
116
+ /**
117
+ * 从 inline flow mapping 文本(如 `{ path: "backend/", test: "cd ..." }`)
118
+ * 提取指定键的值。支持双引号/单引号/bare 值。
119
+ */
120
+ function parseFlowValue(flowText, key) {
121
+ // 键可能带引号也可能不带:path: "x" 或 "path": "x"
122
+ const re = new RegExp(String.raw`(?:^|[{,]\s*)"?${key}"?\s*:\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|(?:[^,}]+?))\s*(?=[,}]|$)`)
123
+ const m = flowText.match(re)
124
+ if (!m) return null
125
+ let v = m[1].trim()
126
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
127
+ v = v.slice(1, -1)
128
+ }
129
+ return v.length > 0 ? v : null
130
+ }
131
+
132
+ /**
133
+ * 根据变更文件列表 + modules 映射,算出被命中的模块(去重保序)。
134
+ * 文件路径以 module.path 为前缀(含子目录)即视为命中。
135
+ *
136
+ * @param {string[]} changedFiles - git diff 产出的相对路径列表
137
+ * @param {Record<string, {path:string, test:string}>} modules - extractModules 解析结果
138
+ * @returns {Array<{name:string, path:string, test:string}>} - 命中的模块(按 modules 声明顺序去重)
139
+ */
140
+ export function pickHitModules(changedFiles, modules) {
141
+ if (!Array.isArray(changedFiles) || changedFiles.length === 0) return []
142
+ if (!modules || typeof modules !== 'object') return []
143
+
144
+ const files = changedFiles.map(f => String(f).replace(/\\/g, '/'))
145
+ const hits = []
146
+ for (const [name, mod] of Object.entries(modules)) {
147
+ if (!mod || !mod.path) continue
148
+ const modPath = String(mod.path).replace(/\\/g, '/')
149
+ const prefix = modPath.endsWith('/') ? modPath : modPath + '/'
150
+ // path 本身被改 或 path/ 下任意文件被改
151
+ const hit = files.some(f => f === modPath || f.startsWith(prefix))
152
+ if (hit) hits.push({ name, path: modPath, test: mod.test })
153
+ }
154
+ return hits
155
+ }
156
+
157
+ /**
158
+ * 聚合多个模块测试结果为单一 status。
159
+ * - 全 passed → 'passed'
160
+ * - 任一 failed → 'failed'
161
+ * - 空 → null(调用方按 fallback 处理)
162
+ *
163
+ * @param {Array<{status:'passed'|'failed'}>} results
164
+ * @returns {'passed'|'failed'|null}
165
+ */
166
+ export function aggregateStatus(results) {
167
+ if (!Array.isArray(results) || results.length === 0) return null
168
+ if (results.every(r => r && r.status === 'passed')) return 'passed'
169
+ return 'failed'
170
+ }
171
+
172
+ /**
173
+ * 跑单个模块的 test 命令(串行调用方逐个调用)。
174
+ * @returns {{name, status:'passed'|'failed', command, exitCode, durationMs, outputTail, reason}}
175
+ */
176
+ function runOneModule(name, testCommand, cwd) {
177
+ const startedAt = Date.now()
178
+ let exitCode = 0
179
+ let output = ''
180
+ let reason = null
181
+ try {
182
+ output = execSync(testCommand, {
183
+ cwd,
184
+ encoding: 'utf8',
185
+ timeout: TEST_TIMEOUT_MS,
186
+ maxBuffer: 32 * 1024 * 1024,
187
+ stdio: ['ignore', 'pipe', 'pipe'],
188
+ })
189
+ } catch (e) {
190
+ exitCode = typeof e.status === 'number' ? e.status : 1
191
+ output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
192
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
193
+ ? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
194
+ : `模块 ${name} 测试退出码 ${exitCode}`
195
+ }
196
+ const durationMs = Date.now() - startedAt
197
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
198
+ return {
199
+ name,
200
+ status: exitCode === 0 ? 'passed' : 'failed',
201
+ command: testCommand,
202
+ exitCode,
203
+ durationMs,
204
+ outputTail,
205
+ reason,
206
+ }
207
+ }
208
+
209
+ /**
210
+ * 取 git 变更文件列表(worktree unstaged + staged,相对仓库根)。
211
+ * 务实策略:先取 unstaged working tree 改动(`git diff --name-only HEAD`),
212
+ * 它同时覆盖已暂存与未暂存改动(相对 HEAD),最适合 worktree 内尚未 commit 的场景。
213
+ * git 不可用 / 非仓库 → 返回 null(调用方 fallback)。
214
+ */
215
+ function gitChangedFiles(cwd) {
216
+ try {
217
+ const out = execSync('git diff --name-only HEAD', {
218
+ cwd,
219
+ encoding: 'utf8',
220
+ timeout: 30 * 1000,
221
+ stdio: ['ignore', 'pipe', 'pipe'],
222
+ })
223
+ return out.split('\n').map(l => l.trim()).filter(Boolean)
224
+ } catch {
225
+ // HEAD 不存在(空仓库)或 git 不可用 → 尝试纯 unstaged
226
+ try {
227
+ const out = execSync('git diff --name-only', {
228
+ cwd,
229
+ encoding: 'utf8',
230
+ timeout: 30 * 1000,
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ })
233
+ return out.split('\n').map(l => l.trim()).filter(Boolean)
234
+ } catch {
235
+ return null
236
+ }
237
+ }
238
+ }
239
+
240
+ /**
241
+ * 执行 verify 实测:读取 local.yaml 配置,按 test_strategy 决定全量或模块子集。
242
+ *
243
+ * @param {object} opts
244
+ * @param {string} opts.cwd - 项目根目录(测试执行目录)
245
+ * @param {string} opts.specBase - .sillyspec(或平台 specRoot)目录
246
+ * @param {string|null} [opts.changeName]
247
+ * @returns {{
248
+ * status: 'passed'|'failed'|'skipped',
249
+ * command: string|null,
250
+ * exitCode: number|null,
251
+ * durationMs: number|null,
252
+ * outputTail: string|null,
253
+ * reason: string|null,
254
+ * resultPath: string|null,
255
+ * }}
256
+ */
257
+ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
258
+ const localYamlPath = join(specBase, 'local.yaml')
259
+ const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
260
+
261
+ // —— 模块子集路径(test_strategy: module)——
262
+ const strategy = extractTestStrategy(yamlText)
263
+ if (strategy === 'module') {
264
+ const modules = extractModules(yamlText)
265
+ if (modules) {
266
+ const changedFiles = gitChangedFiles(cwd)
267
+ const hits = changedFiles ? pickHitModules(changedFiles, modules) : []
268
+ if (hits.length > 0) {
269
+ return runModuleSubset({ cwd, specBase, changeName, hits })
270
+ }
271
+ // 有 modules 配置但无命中 / git 不可用 → fallback commands.test(D-002@v1 向后兼容)
272
+ }
273
+ // test_strategy:module 但无 modules 配置 → fallback commands.test(brownfield 友好)
274
+ }
275
+
276
+ // —— 全量路径(默认 full / fallback)——
277
+ return runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName })
278
+ }
279
+
280
+ /**
281
+ * 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
282
+ */
283
+ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName }) {
284
+ const command = extractTestCommand(yamlText)
285
+
286
+ if (!command) {
287
+ return {
288
+ status: 'skipped',
289
+ command: null,
290
+ exitCode: null,
291
+ durationMs: null,
292
+ outputTail: null,
293
+ reason: yamlText
294
+ ? 'local.yaml 未配置 commands.test(或标记 unavailable)'
295
+ : `local.yaml 不存在(${localYamlPath})`,
296
+ resultPath: null,
297
+ }
298
+ }
299
+
300
+ const startedAt = Date.now()
301
+ let exitCode = 0
302
+ let output = ''
303
+ let reason = null
304
+ try {
305
+ output = execSync(command, {
306
+ cwd,
307
+ encoding: 'utf8',
308
+ timeout: TEST_TIMEOUT_MS,
309
+ maxBuffer: 32 * 1024 * 1024,
310
+ stdio: ['ignore', 'pipe', 'pipe'],
311
+ })
312
+ } catch (e) {
313
+ exitCode = typeof e.status === 'number' ? e.status : 1
314
+ output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
315
+ reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
316
+ ? `测试命令超时(>${TEST_TIMEOUT_MS / 1000}s)`
317
+ : `测试命令退出码 ${exitCode}`
318
+ }
319
+ const durationMs = Date.now() - startedAt
320
+ const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
321
+
322
+ const result = {
323
+ status: exitCode === 0 ? 'passed' : 'failed',
324
+ command,
325
+ exitCode,
326
+ durationMs,
327
+ outputTail,
328
+ reason,
329
+ resultPath: null,
330
+ }
331
+
332
+ writeRunResult({ specBase, changeName, result })
333
+ return result
334
+ }
335
+
336
+ /**
337
+ * 串行跑命中的模块子集,聚合结果。
338
+ * 返回 shape 与 runFullCommand 一致(status/command/exitCode/durationMs/outputTail/reason/resultPath)。
339
+ */
340
+ function runModuleSubset({ cwd, specBase, changeName, hits }) {
341
+ const subsetStartedAt = Date.now()
342
+ const perModule = hits.map(h => runOneModule(h.name, h.test, cwd))
343
+ const status = aggregateStatus(perModule)
344
+
345
+ const command = `module[${hits.map(h => h.name).join(',')}]`
346
+ const exitCode = status === 'passed' ? 0 : 1
347
+ const durationMs = Date.now() - subsetStartedAt
348
+
349
+ // 合并各模块输出尾部(标注模块名)
350
+ const outputTail = perModule
351
+ .map(r => `── module ${r.name} (${r.status}) ──\n${r.outputTail || ''}`)
352
+ .join('\n')
353
+ const reason = status === 'passed'
354
+ ? null
355
+ : `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
356
+
357
+ const result = {
358
+ status,
359
+ command,
360
+ exitCode,
361
+ durationMs,
362
+ outputTail: outputTail.length > OUTPUT_TAIL_CHARS ? '…' + outputTail.slice(-OUTPUT_TAIL_CHARS) : outputTail,
363
+ reason,
364
+ resultPath: null,
365
+ }
366
+
367
+ writeRunResult({
368
+ specBase,
369
+ changeName,
370
+ result,
371
+ extra: {
372
+ modules: perModule.map(r => ({
373
+ name: r.name,
374
+ command: r.command,
375
+ exit_code: r.exitCode,
376
+ status: r.status,
377
+ duration_ms: r.durationMs,
378
+ output_tail: r.outputTail,
379
+ reason: r.reason,
380
+ })),
381
+ },
382
+ })
383
+ return result
384
+ }
385
+
386
+ /**
387
+ * 结果落盘到 .runtime/verify-runs/<ts>/test-result.json(供追溯与 SillyHub 消费)。
388
+ * 多模块时 extra.modules 描述各模块明细。
389
+ */
390
+ function writeRunResult({ specBase, changeName, result, extra = {} }) {
391
+ try {
392
+ const ts = new Date().toISOString().slice(0, 19).replace(/[-T:]/g, '')
393
+ const runDir = join(specBase, '.runtime', 'verify-runs', ts)
394
+ mkdirSync(runDir, { recursive: true })
395
+ const resultPath = join(runDir, 'test-result.json')
396
+ writeFileSync(resultPath, JSON.stringify({
397
+ change: changeName,
398
+ command: result.command,
399
+ exit_code: result.exitCode,
400
+ status: result.status,
401
+ duration_ms: result.durationMs,
402
+ output_tail: result.outputTail,
403
+ reason: result.reason,
404
+ ran_at: new Date().toISOString(),
405
+ ...extra,
406
+ }, null, 2) + '\n')
407
+ result.resultPath = resultPath
408
+ } catch (e) {
409
+ console.warn(`⚠️ verify 实测结果落盘失败: ${e.message}`)
410
+ }
411
+ }
412
+
413
+ /** 打印实测结果(人类/agent 可读) */
414
+ export function printVerifyTestCheck(result) {
415
+ if (result.status === 'skipped') {
416
+ console.warn(`\n⚠️ Verify 实测跳过:${result.reason}`)
417
+ console.warn(' 建议在 local.yaml 的 commands.test 配置真实测试命令,让 CLI 可客观对账。')
418
+ return
419
+ }
420
+ if (result.status === 'passed') {
421
+ console.log(`\n✅ Verify 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
422
+ } else {
423
+ console.error(`\n❌ Verify 实测失败:\`${result.command}\` — ${result.reason}`)
424
+ if (result.outputTail) {
425
+ const tail = result.outputTail.split('\n').slice(-20).join('\n')
426
+ console.error(' 输出(末尾):')
427
+ for (const line of tail.split('\n')) console.error(` | ${line}`)
428
+ }
429
+ }
430
+ if (result.resultPath) {
431
+ console.log(`📄 实测结果已写入: ${result.resultPath}`)
432
+ }
433
+ }