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/package.json +1 -1
- package/src/hooks/worktree-guard.js +86 -31
- package/src/index.js +147 -7
- package/src/local-detect.js +110 -0
- package/src/machine-interface.js +471 -0
- package/src/progress.js +110 -5
- package/src/run.js +176 -48
- package/src/stage-contract.js +107 -1
- package/src/stages/execute.js +2 -2
- package/src/stages/quick.js +11 -0
- package/src/stages/scan.js +7 -16
- package/src/stages/verify.js +3 -1
- package/src/sync.js +73 -5
- package/src/task-review.js +110 -4
- package/src/verify-postcheck.js +433 -0
- package/src/worktree-apply.js +73 -6
- package/src/worktree.js +12 -0
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
423
|
-
console.warn(`[sync] reject 尚未实现 (${changeName})`);
|
|
491
|
+
return _submitApproval(cwd, changeName, 'rejected', reason);
|
|
424
492
|
}
|
|
425
493
|
|
|
426
494
|
export async function status(cwd) {
|
package/src/task-review.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|