sillyspec 3.26.15 → 3.27.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/.claude/skills/sillyspec-explore/SKILL.md +10 -3
- package/.claude/skills/sillyspec-verify/SKILL.md +2 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/packages/dashboard/server/index.js +22 -6
- package/src/agent-session-log.js +763 -0
- package/src/change-list.js +22 -2
- package/src/change-risk-profile.js +21 -6
- package/src/contract-matrix.js +91 -7
- package/src/db.js +14 -3
- package/src/docs-check.js +598 -570
- package/src/doctor-diagnostics.js +53 -1
- package/src/endpoint-extractor.js +21 -2
- package/src/fs-atomic.js +4 -2
- package/src/git-helper.js +73 -5
- package/src/hooks/claude-pre-tool-use.cjs +10 -5
- package/src/hooks/worktree-guard.js +22 -3
- package/src/index.js +59 -7
- package/src/module-impact.js +21 -1
- package/src/module-resolve.js +211 -0
- package/src/modules.js +82 -2
- package/src/progress/step-store.js +12 -8
- package/src/progress.js +6 -2
- package/src/quicklog.js +11 -3
- package/src/run/command.js +40 -0
- package/src/run/complete-handlers.js +17 -2
- package/src/run/complete.js +81 -8
- package/src/run/gates.js +83 -14
- package/src/run/prompt.js +16 -2
- package/src/run/quick-audit.js +22 -2
- package/src/run/shared.js +70 -19
- package/src/run/stage.js +60 -3
- package/src/stage-contract.js +14 -1
- package/src/stages/archive.js +3 -1
- package/src/stages/execute.js +171 -18
- package/src/stages/explore.js +11 -2
- package/src/stages/plan-postcheck.js +3 -1
- package/src/stages/plan.js +3 -0
- package/src/stages/quick.js +4 -3
- package/src/stages/verify.js +9 -2
- package/src/sync.js +98 -3
- package/src/task-review.js +55 -7
- package/src/taskcard.js +52 -22
- package/src/verify-postcheck.js +223 -6
- package/src/worktree-apply.js +28 -10
- package/src/worktree-deps.js +71 -5
- package/src/worktree.js +70 -6
package/src/task-review.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSync } from 'fs'
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
|
-
import { git } from './git-helper.js'
|
|
13
|
+
import { git, unquoteGitPath } from './git-helper.js'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
15
|
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
@@ -378,6 +378,30 @@ function isTaskLowRisk(changeDir, taskId) {
|
|
|
378
378
|
}
|
|
379
379
|
}
|
|
380
380
|
|
|
381
|
+
/**
|
|
382
|
+
* 纯验证 task 判定(坑 verification-task-zero-diff,2026-08-21 实证):task-10/11 类
|
|
383
|
+
* 「跑测试/部署验证/观测确认」型任务无代码 diff 是本质属性而非伪造——emptyDiff 判伪造
|
|
384
|
+
* 对它们必误杀,此前只能引用验证区间并手工披露归属绕过。task 卡 frontmatter 显式声明
|
|
385
|
+
* `task_type: verification`(或 `verification_only: true`)即走合法零 diff 通道:
|
|
386
|
+
* emptyDiff 不判伪造(转 warning),但 review 必须带非空 requiredEvidence(披露验证
|
|
387
|
+
* 区间/结论出处)——不能拿声明逃掉披露义务。
|
|
388
|
+
* @returns {boolean}
|
|
389
|
+
*/
|
|
390
|
+
function isTaskVerification(changeDir, taskId) {
|
|
391
|
+
if (!changeDir) return false
|
|
392
|
+
const taskFile = join(changeDir, 'tasks', `${taskId}.md`)
|
|
393
|
+
if (!existsSync(taskFile)) return false
|
|
394
|
+
try {
|
|
395
|
+
const content = readFileSync(taskFile, 'utf8')
|
|
396
|
+
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/)
|
|
397
|
+
if (!fm) return false
|
|
398
|
+
return /^task_type:\s*['"]?verification['"]?\s*$/im.test(fm[1])
|
|
399
|
+
|| /^verification_only:\s*true\s*$/im.test(fm[1])
|
|
400
|
+
} catch {
|
|
401
|
+
return false
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
381
405
|
export function validateTaskReviews(opts) {
|
|
382
406
|
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
|
|
383
407
|
|
|
@@ -463,6 +487,15 @@ export function validateTaskReviews(opts) {
|
|
|
463
487
|
if (evidence.emptyDiff) {
|
|
464
488
|
if (isTaskLowRisk(changeDir, taskId)) {
|
|
465
489
|
warnings.push(`${taskId}: base..head 无代码变更(task 声明 low_risk: true,不阻断)`)
|
|
490
|
+
} else if (isTaskVerification(changeDir, taskId)) {
|
|
491
|
+
// 纯验证 task 零 diff 通道(坑 verification-task-zero-diff):声明 task_type: verification
|
|
492
|
+
// 的「跑测试/部署验证/观测」型任务无代码 diff 是本质属性——不判伪造,但披露义务不豁免:
|
|
493
|
+
// requiredEvidence 为空 → error(声明不能替代证据)
|
|
494
|
+
if (Array.isArray(review.requiredEvidence) && review.requiredEvidence.length > 0) {
|
|
495
|
+
warnings.push(`${taskId}: base..head 无代码变更 — task 声明 task_type: verification(纯验证任务),零 diff 合法;requiredEvidence 已披露验证区间/结论出处`)
|
|
496
|
+
} else {
|
|
497
|
+
errors.push(`${taskId}: 纯验证 task(task_type: verification)零 diff 合法,但 review 缺 requiredEvidence(须披露验证区间/命令/结论出处)——声明不能替代披露`)
|
|
498
|
+
}
|
|
466
499
|
} else {
|
|
467
500
|
errors.push(`${taskId}: base..head(${review.base.slice(0, 8)}..${review.head.slice(0, 8)})无任何代码变更 — 评审了一个零改动的任务,review 疑似伪造`)
|
|
468
501
|
continue
|
|
@@ -539,7 +572,7 @@ function parsePorcelainFiles(statusOut) {
|
|
|
539
572
|
for (const line of String(statusOut).split('\n')) {
|
|
540
573
|
if (line.trim() === '') continue
|
|
541
574
|
let p = line.slice(3).trim()
|
|
542
|
-
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1)
|
|
575
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) p = unquoteGitPath(p.slice(1, -1))
|
|
543
576
|
const arrow = p.indexOf(' -> ')
|
|
544
577
|
if (arrow !== -1) p = p.slice(arrow + 4)
|
|
545
578
|
if (p) files.push(p.replace(/\\/g, '/'))
|
|
@@ -611,7 +644,11 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
|
611
644
|
runGit(gitDir, ['rev-parse', '--verify', '--quiet', `${hash}^{commit}`])
|
|
612
645
|
c.verified.add(vkey)
|
|
613
646
|
} catch {
|
|
614
|
-
|
|
647
|
+
// 可执行指引(坑 review-head-real-commit-late,2026-08-22 实证:head 必须真实 commit 的
|
|
648
|
+
// 要求撞门才知道——报错直接给操作序列:worktree 改动先 commit,取 HEAD 作 head)
|
|
649
|
+
errors.push(`${field} "${hash}" 不是仓库中的真实 commit — review.json 疑似伪造。` +
|
|
650
|
+
`head/base 必须是 git rev-parse 可解析的 commit hash:worktree 内先 git add -A && git commit -m "<task>",` +
|
|
651
|
+
`再取 git rev-parse HEAD 作 head(base 用 task 卡锡点或基线 commit);不要填分支名/伪 hash/working-tree 描述`)
|
|
615
652
|
}
|
|
616
653
|
}
|
|
617
654
|
if (errors.length > 0) {
|
|
@@ -659,15 +696,25 @@ export function verifyReviewGitEvidence(review, gitDir, cache = null) {
|
|
|
659
696
|
|
|
660
697
|
// changedFiles 交叉比对:完全不相交 = review 描述的改动与实际 diff 无关(diffFiles 已含 working-tree)
|
|
661
698
|
if (!emptyDiff && Array.isArray(review.changedFiles) && review.changedFiles.length > 0) {
|
|
662
|
-
|
|
699
|
+
// 注记剥离(坑 changedfiles-annotation-suffix-mismatch,2026-08-22 实证:changedFiles 带
|
|
700
|
+
// 「src/a.js(新增)」类注记后缀被判完全不相交,agent 用正则修还贪婪吞了路径段两轮才对)。
|
|
701
|
+
// 结构化剥法:只剥「路径尾部」的注记形态——全角/半角括号注记、// 与 # 行内注释、空白后的
|
|
702
|
+
// 说明文字;注记本身不参与匹配(想写说明放 reviewerNotes/requiredEvidence)。
|
|
703
|
+
const normalize = (p) => String(p)
|
|
704
|
+
.replace(/^\.\//, '')
|
|
705
|
+
.replace(/[((][^(())]*[))]\s*$/, '') // 尾部(注记)/(note)——中段括号(路径段)不动
|
|
706
|
+
.replace(/\s+(\/\/|#).*$/, '') // 空格后的 // 或 # 注释
|
|
707
|
+
.trim()
|
|
663
708
|
const diffSet = new Set(diffFiles.map(normalize))
|
|
664
709
|
const hasOverlap = review.changedFiles.some(f => {
|
|
665
710
|
const nf = normalize(f)
|
|
711
|
+
if (!nf) return false
|
|
666
712
|
// 兼容 review 写相对/部分路径:前后缀匹配即可
|
|
667
713
|
return diffSet.has(nf) || diffFiles.some(d => d.endsWith(nf) || nf.endsWith(d))
|
|
668
714
|
})
|
|
669
715
|
if (!hasOverlap) {
|
|
670
|
-
errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review
|
|
716
|
+
errors.push(`changedFiles 与实际 git diff(${review.base.slice(0, 8)}..${review.head.slice(0, 8)},${diffFiles.length} 个文件)完全不相交 — review 内容与代码变更无关。` +
|
|
717
|
+
`检查:changedFiles 必须是纯文件路径(注记/说明写 reviewerNotes,不拼进路径数组);路径拼写与 diff 对照`)
|
|
671
718
|
}
|
|
672
719
|
}
|
|
673
720
|
|
|
@@ -1135,8 +1182,9 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
1135
1182
|
specVerdict: 'cannot_verify',
|
|
1136
1183
|
qualityVerdict: 'cannot_verify',
|
|
1137
1184
|
requiredEvidence: ['auto-generated draft (no-attributed-diff): ' + taskId +
|
|
1138
|
-
' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 /
|
|
1139
|
-
'需人工确认实际改动后升级 verdict,确属未实现则回 fail'
|
|
1185
|
+
' 的 allowed_paths 未命中本次 diff 任何文件(task 未实现 / 改动与他人同文件 / 路径不匹配 / 纯验证任务)——' +
|
|
1186
|
+
'需人工确认实际改动后升级 verdict,确属未实现则回 fail;若是纯验证任务(跑测试/部署验证/观测,无代码 diff 是本质属性),' +
|
|
1187
|
+
'task 卡 frontmatter 声明 task_type: verification + review 披露验证区间即走合法零 diff 通道'],
|
|
1140
1188
|
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) +
|
|
1141
1189
|
';verdict=未评审(无归属 diff 兜底草稿,坑 task-review-draft-skip-leak)',
|
|
1142
1190
|
}
|
package/src/taskcard.js
CHANGED
|
@@ -14,7 +14,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
|
|
|
14
14
|
import { join } from 'path'
|
|
15
15
|
import { git } from './git-helper.js'
|
|
16
16
|
import { assertSafeChangeName, resolveSpecDir } from './run/shared.js'
|
|
17
|
-
import { parseTaskNames } from './stages/plan.js'
|
|
17
|
+
import { parseTaskNames, readTaskRegistryContent } from './stages/plan.js'
|
|
18
|
+
import { parseTaskRegistry } from './stages/execute.js'
|
|
18
19
|
|
|
19
20
|
/**
|
|
20
21
|
* 任务号归一:接受 task-01 / task-1 / 01 / 1,统一为 task-NN(两位补零,与任务卡文件名一致)。
|
|
@@ -52,21 +53,44 @@ function yamlScalar(v) {
|
|
|
52
53
|
* 字符串只含 \n:writeFileSync 直写不经任何行尾转换,Windows 下天然 LF 安全。
|
|
53
54
|
* title/title_zh/author 经 yamlScalar 转义(自由文本防 YAML 指示符炸解析)。
|
|
54
55
|
*
|
|
55
|
-
* @param {{ taskId: string, title: string, titleZh: string, author: string, now: string
|
|
56
|
+
* @param {{ taskId: string, title: string, titleZh: string, author: string, now: string,
|
|
57
|
+
* dependsOn?: string[], sets?: Record<string,string> }} p
|
|
58
|
+
* dependsOn:tasks.md 行内注解反填(坑 taskcard-design-field-conflicts,2026-08-21 实证)——
|
|
59
|
+
* "(depends_on: task-01,02)" 注解由 CLI 解析进 frontmatter,省主代理逐卡裁决
|
|
60
|
+
* sets:--set key=value 覆盖(yamlScalar 转义;仅覆盖已存在的标量键,防注入任意结构)
|
|
56
61
|
* @returns {string}
|
|
57
62
|
*/
|
|
58
|
-
export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now }) {
|
|
63
|
+
export function buildTaskcardSkeleton({ taskId, title, titleZh, author, now, dependsOn = [], sets = {} }) {
|
|
64
|
+
const fm = {
|
|
65
|
+
id: taskId,
|
|
66
|
+
title: yamlScalar(title),
|
|
67
|
+
title_zh: yamlScalar(titleZh),
|
|
68
|
+
author: yamlScalar(author),
|
|
69
|
+
created_at: now,
|
|
70
|
+
priority: 'P0',
|
|
71
|
+
depends_on: (Array.isArray(dependsOn) && dependsOn.length > 0)
|
|
72
|
+
? `[${dependsOn.map(d => `'${d}'`).join(', ')}]`
|
|
73
|
+
: '[]',
|
|
74
|
+
blocks: '[]',
|
|
75
|
+
requirement_ids: '[FR-XX]',
|
|
76
|
+
decision_ids: '[D-XXX@vN]',
|
|
77
|
+
}
|
|
78
|
+
// --set 覆盖(白名单键 + yamlScalar 转义值;depends_on 数组形态单独拼不进标量 map,跳过)
|
|
79
|
+
const SETTABLE_KEYS = new Set(['id', 'title', 'title_zh', 'author', 'priority', 'created_at', 'requirement_ids', 'decision_ids', 'blocks', 'goal'])
|
|
80
|
+
for (const [k, v] of Object.entries(sets || {})) {
|
|
81
|
+
if (SETTABLE_KEYS.has(k)) fm[k] = yamlScalar(v)
|
|
82
|
+
}
|
|
59
83
|
return `---
|
|
60
|
-
id: ${
|
|
61
|
-
title: ${
|
|
62
|
-
title_zh: ${
|
|
63
|
-
author: ${
|
|
64
|
-
created_at: ${
|
|
65
|
-
priority:
|
|
66
|
-
depends_on:
|
|
67
|
-
blocks:
|
|
68
|
-
requirement_ids:
|
|
69
|
-
decision_ids:
|
|
84
|
+
id: ${fm.id}
|
|
85
|
+
title: ${fm.title}
|
|
86
|
+
title_zh: ${fm.title_zh}
|
|
87
|
+
author: ${fm.author}
|
|
88
|
+
created_at: ${fm.created_at}
|
|
89
|
+
priority: ${fm.priority}
|
|
90
|
+
depends_on: ${fm.depends_on}
|
|
91
|
+
blocks: ${fm.blocks}
|
|
92
|
+
requirement_ids: ${fm.requirement_ids}
|
|
93
|
+
decision_ids: ${fm.decision_ids}
|
|
70
94
|
allowed_paths:
|
|
71
95
|
- src/example/file.ts
|
|
72
96
|
goal: >
|
|
@@ -105,7 +129,7 @@ constraints:
|
|
|
105
129
|
* @returns {{ created: string[], skipped: string[], tasksDir: string }}
|
|
106
130
|
*/
|
|
107
131
|
export function cmdTaskcard(changeName, opts = {}) {
|
|
108
|
-
const { cwd, specDir = null, taskIds, title = null, titleZh = null, force = false } = opts
|
|
132
|
+
const { cwd, specDir = null, taskIds, title = null, titleZh = null, force = false, sets = {} } = opts
|
|
109
133
|
assertSafeChangeName(changeName, '变更名')
|
|
110
134
|
|
|
111
135
|
const changeDir = join(resolveSpecDir(cwd, { specDir }), 'changes', changeName)
|
|
@@ -114,25 +138,27 @@ export function cmdTaskcard(changeName, opts = {}) {
|
|
|
114
138
|
}
|
|
115
139
|
|
|
116
140
|
// 任务注册表 tasks.md(2026-08-20-task-truth-unify 唯一真相;CRLF 归一后解析,
|
|
117
|
-
// --all 时强依赖)。tasks.md 缺失回退 plan.md
|
|
118
|
-
|
|
141
|
+
// --all 时强依赖)。tasks.md 缺失回退 plan.md(旧归档变更兼容读侧)。
|
|
142
|
+
// parseTaskRegistry(坑 taskcard-design-field-conflicts,2026-08-21):除编号/标题外还带出
|
|
143
|
+
// 行内注解([model:xxx] / (depends_on: task-01,02))——depends_on 直接反填进骨架 frontmatter。
|
|
144
|
+
let registry = []
|
|
119
145
|
const tasksMdPath = join(changeDir, 'tasks.md')
|
|
120
146
|
const planPath = join(changeDir, 'plan.md')
|
|
121
147
|
const registryPath = existsSync(tasksMdPath) ? tasksMdPath : planPath
|
|
122
148
|
if (existsSync(registryPath)) {
|
|
123
149
|
const registryContent = readFileSync(registryPath, 'utf8').replace(/\r\n/g, '\n')
|
|
124
|
-
|
|
150
|
+
registry = parseTaskRegistry(registryContent)
|
|
125
151
|
}
|
|
126
152
|
|
|
127
153
|
let ids
|
|
128
154
|
if (taskIds === 'all') {
|
|
129
|
-
if (
|
|
155
|
+
if (registry.length === 0) {
|
|
130
156
|
throw new Error(
|
|
131
157
|
`--all 需要 tasks.md 中存在 checkbox 任务行(格式 "- [ ] task-XX: 任务名")` +
|
|
132
158
|
(existsSync(registryPath) ? ',当前任务清单未解析到任何任务行' : ',且变更目录下未找到 tasks.md/plan.md')
|
|
133
159
|
)
|
|
134
160
|
}
|
|
135
|
-
ids =
|
|
161
|
+
ids = registry.map(t => t.id)
|
|
136
162
|
} else if (Array.isArray(taskIds) && taskIds.length > 0) {
|
|
137
163
|
ids = taskIds
|
|
138
164
|
} else {
|
|
@@ -161,9 +187,9 @@ export function cmdTaskcard(changeName, opts = {}) {
|
|
|
161
187
|
const created = []
|
|
162
188
|
const skipped = []
|
|
163
189
|
for (const id of ids) {
|
|
164
|
-
const
|
|
165
|
-
const finalTitle = title || (
|
|
166
|
-
const finalTitleZh = titleZh || (
|
|
190
|
+
const reg = registry.find(t => t.id === id)
|
|
191
|
+
const finalTitle = title || (reg ? reg.name : null) || `${id} fill-english-title`
|
|
192
|
+
const finalTitleZh = titleZh || (reg ? reg.name : null) || finalTitle
|
|
167
193
|
const filePath = join(tasksDir, `${id}.md`)
|
|
168
194
|
if (existsSync(filePath) && !force) {
|
|
169
195
|
skipped.push(filePath)
|
|
@@ -171,6 +197,10 @@ export function cmdTaskcard(changeName, opts = {}) {
|
|
|
171
197
|
}
|
|
172
198
|
writeFileSync(filePath, buildTaskcardSkeleton({
|
|
173
199
|
taskId: id, title: finalTitle, titleZh: finalTitleZh, author, now,
|
|
200
|
+
// depends_on 从 tasks.md 行内注解反填(坑 taskcard-design-field-conflicts):注册表
|
|
201
|
+
// 注解与 plan/design 的依赖声明同源,骨架直接带上省主代理逐卡转录
|
|
202
|
+
dependsOn: reg ? reg.dependsOn : [],
|
|
203
|
+
sets,
|
|
174
204
|
}), 'utf8')
|
|
175
205
|
created.push(filePath)
|
|
176
206
|
}
|
package/src/verify-postcheck.js
CHANGED
|
@@ -60,6 +60,104 @@ export function extractTestCommand(yamlText) {
|
|
|
60
60
|
return null
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/**
|
|
64
|
+
* 从 local.yaml 文本提取 commands.lint(2026-08-21 审查 CLI-1:lint 对账)。
|
|
65
|
+
* 与 extractTestCommand 同风格同容错(带引号/不带引号/unavailable)。
|
|
66
|
+
* 模块内私有(runVerifyLintCheck 消费;extractTestCommand 系 export 供 test)。
|
|
67
|
+
*/
|
|
68
|
+
function extractLintCommand(yamlText) {
|
|
69
|
+
if (!yamlText) return null
|
|
70
|
+
const doubleQuoted = yamlText.match(/^\s*lint:\s*"([^"]+)"\s*(?:#.*)?$/m)
|
|
71
|
+
const singleQuoted = yamlText.match(/^\s*lint:\s*'([^']+)'\s*(?:#.*)?$/m)
|
|
72
|
+
const quoted = doubleQuoted || singleQuoted
|
|
73
|
+
if (quoted && quoted[1]) {
|
|
74
|
+
return quoted[1].toLowerCase() === 'unavailable' ? null : quoted[1].trim()
|
|
75
|
+
}
|
|
76
|
+
const bare = yamlText.match(/^\s*lint:\s*([^\n#"']+?)\s*(?:#.*)?$/m)
|
|
77
|
+
if (bare && bare[1]) {
|
|
78
|
+
const cmd = bare[1].trim()
|
|
79
|
+
return cmd.toLowerCase() === 'unavailable' ? null : cmd
|
|
80
|
+
}
|
|
81
|
+
return null
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* verify --done 实测跑 local.yaml commands.lint(2026-08-21 审查 CLI-1)。
|
|
86
|
+
* 此前 lint 全靠 agent 自跑自报("我跑过 lint 了"纯口头),与 test 侧的对账不对称——
|
|
87
|
+
* agent 偷懒漏跑时格式债被推迟到用户 commit 才被 pre-commit hook 炸出。
|
|
88
|
+
* advisory 起步:失败只打印不阻断(test 门已 fail-closed,lint 门观察期后再升级)。
|
|
89
|
+
* 信任边界与 runVerifyTestCheck 一致:命令只来源于主仓 .sillyspec/local.yaml。
|
|
90
|
+
*/
|
|
91
|
+
export function runVerifyLintCheck({ cwd, specBase }) {
|
|
92
|
+
const localYamlPath = join(specBase, 'local.yaml')
|
|
93
|
+
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
94
|
+
const command = extractLintCommand(yamlText)
|
|
95
|
+
|
|
96
|
+
if (!command) {
|
|
97
|
+
return {
|
|
98
|
+
status: 'skipped',
|
|
99
|
+
command: null,
|
|
100
|
+
exitCode: null,
|
|
101
|
+
durationMs: null,
|
|
102
|
+
outputTail: null,
|
|
103
|
+
reason: yamlText
|
|
104
|
+
? 'local.yaml 未配置 commands.lint(或标记 unavailable)'
|
|
105
|
+
: `local.yaml 不存在(${localYamlPath})`,
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const LINT_TIMEOUT_MS = Number(process.env.SILLYSPEC_LINT_TIMEOUT_MS) || 3 * 60 * 1000
|
|
110
|
+
const startedAt = Date.now()
|
|
111
|
+
let exitCode = 0
|
|
112
|
+
let output = ''
|
|
113
|
+
let reason = null
|
|
114
|
+
try {
|
|
115
|
+
output = execSync(command, {
|
|
116
|
+
cwd,
|
|
117
|
+
encoding: 'utf8',
|
|
118
|
+
timeout: LINT_TIMEOUT_MS,
|
|
119
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
120
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
121
|
+
})
|
|
122
|
+
} catch (e) {
|
|
123
|
+
exitCode = typeof e.status === 'number' ? e.status : 1
|
|
124
|
+
output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
|
|
125
|
+
reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= LINT_TIMEOUT_MS
|
|
126
|
+
? `lint 命令超时(>${LINT_TIMEOUT_MS / 1000}s)`
|
|
127
|
+
: `lint 命令退出码 ${exitCode}`
|
|
128
|
+
}
|
|
129
|
+
const durationMs = Date.now() - startedAt
|
|
130
|
+
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
131
|
+
|
|
132
|
+
return {
|
|
133
|
+
status: exitCode === 0 ? 'passed' : 'failed',
|
|
134
|
+
command,
|
|
135
|
+
exitCode,
|
|
136
|
+
durationMs,
|
|
137
|
+
outputTail,
|
|
138
|
+
reason: exitCode === 0 ? null : reason,
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** 打印 lint 实测结果(advisory:失败不阻断,只把口头汇报对上账) */
|
|
143
|
+
export function printVerifyLintCheck(result) {
|
|
144
|
+
if (result.status === 'skipped') {
|
|
145
|
+
console.warn(`\n⚠️ Verify lint 实测跳过:${result.reason}`)
|
|
146
|
+
return
|
|
147
|
+
}
|
|
148
|
+
if (result.status === 'passed') {
|
|
149
|
+
console.log(`\n✅ Verify lint 实测通过:\`${result.command}\` 退出码 0(${(result.durationMs / 1000).toFixed(1)}s)`)
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
console.error(`\n⚠️ Verify lint 实测失败(advisory,不阻断本次完成):\`${result.command}\` — ${result.reason}`)
|
|
153
|
+
console.error(' agent 的 lint 自报告与实测不符时以实测为准;请修复后重跑,避免格式债推迟到 commit 被 pre-commit hook 拦截。')
|
|
154
|
+
if (result.outputTail) {
|
|
155
|
+
const tail = result.outputTail.split('\n').slice(-15).join('\n')
|
|
156
|
+
console.error(' 输出(末尾):')
|
|
157
|
+
for (const line of tail.split('\n')) console.error(` | ${line}`)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
63
161
|
/**
|
|
64
162
|
* 从 local.yaml 文本提取顶层 test_strategy。
|
|
65
163
|
* 轻量正则(与 extractTestCommand 同风格,不引 yaml 依赖)。
|
|
@@ -207,15 +305,30 @@ export function pickHitModules(changedFiles, modules) {
|
|
|
207
305
|
|
|
208
306
|
const files = changedFiles.map(f => String(f).replace(/\\/g, '/'))
|
|
209
307
|
const hits = []
|
|
308
|
+
const looseHits = []
|
|
210
309
|
for (const [name, mod] of Object.entries(modules)) {
|
|
211
310
|
if (!mod || !mod.path) continue
|
|
212
311
|
const modPath = String(mod.path).replace(/\\/g, '/')
|
|
213
312
|
const prefix = modPath.endsWith('/') ? modPath : modPath + '/'
|
|
214
313
|
// path 本身被改 或 path/ 下任意文件被改
|
|
215
314
|
const hit = files.some(f => f === modPath || f.startsWith(prefix))
|
|
216
|
-
if (hit) hits.push({ name, path: modPath, test: mod.test })
|
|
315
|
+
if (hit) { hits.push({ name, path: modPath, test: mod.test }); continue }
|
|
316
|
+
// 宽松段匹配 fallback(坑 module-path-layout-mismatch,2026-08-22 实证:pnpm monorepo
|
|
317
|
+
// 常见 packages/frontend 布局 vs modules 配 frontend/ 前缀——严格前缀 0 命中时按
|
|
318
|
+
// 「路径段等于模块首段」兜底(packages/frontend/src/x → 命中 frontend),多测不漏测。
|
|
319
|
+
// 首段去斜杠后比对,防 frontend-guide 之类前缀误蹭)
|
|
320
|
+
const firstSeg = prefix.split('/')[0]
|
|
321
|
+
if (firstSeg && files.some(f => ('/' + f + '/').includes('/' + firstSeg + '/'))) {
|
|
322
|
+
looseHits.push({ name, path: modPath, test: mod.test, looseMatch: true })
|
|
323
|
+
}
|
|
217
324
|
}
|
|
218
|
-
|
|
325
|
+
// 严格命中优先;全部严格 0 命中才用宽松兜底(并 warn 可见——宽松规则可能多命中)
|
|
326
|
+
if (hits.length > 0) return hits
|
|
327
|
+
if (looseHits.length > 0) {
|
|
328
|
+
console.warn(`⚠️ 模块命中用宽松段匹配(modules path 前缀与 diff 布局不一致,如 packages/<name> vs <name>/):命中 ${looseHits.map(h => h.name).join(', ')}——建议 local.yaml modules 的 path 对齐实际目录布局`)
|
|
329
|
+
return looseHits
|
|
330
|
+
}
|
|
331
|
+
return []
|
|
219
332
|
}
|
|
220
333
|
|
|
221
334
|
/**
|
|
@@ -360,6 +473,7 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
|
|
|
360
473
|
let exitCode = 0
|
|
361
474
|
let output = ''
|
|
362
475
|
let reason = null
|
|
476
|
+
warnPortRaceBeforeRun(testCommand)
|
|
363
477
|
try {
|
|
364
478
|
output = execSync(testCommand, {
|
|
365
479
|
cwd,
|
|
@@ -374,6 +488,10 @@ function runOneModule(name, testCommand, cwd, knownFailures = []) {
|
|
|
374
488
|
reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
|
|
375
489
|
? `模块 ${name} 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
|
|
376
490
|
: `模块 ${name} 测试退出码 ${exitCode}`
|
|
491
|
+
// 资源竞争鉴别提示(坑 verify-devserver-port-race,同 runFullCommand)
|
|
492
|
+
if (/EADDRINUSE|address already in use|端口.*被占用|port.*already/i.test(output)) {
|
|
493
|
+
reason += '。⚠️ 输出含端口占用信号(EADDRINUSE)——极可能是与你自留 dev server 的资源竞争而非代码问题:停掉占用服务后重跑 verify 再定论'
|
|
494
|
+
}
|
|
377
495
|
}
|
|
378
496
|
const durationMs = Date.now() - startedAt
|
|
379
497
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
@@ -475,9 +593,35 @@ function gitChangedFiles(cwd) {
|
|
|
475
593
|
* @param {object|null} [ctx] - MultiRepoContext 实例(可选,缺省/null 走单仓原逻辑)
|
|
476
594
|
* @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
|
|
477
595
|
*/
|
|
478
|
-
export function resolveVerifyChangedFiles(cwd, changeName, ctx = null) {
|
|
596
|
+
export function resolveVerifyChangedFiles(cwd, changeName, ctx = null, opts = {}) {
|
|
597
|
+
const { includeWorkingTree = false } = opts
|
|
479
598
|
// 主仓 diff(原逻辑不动,单仓零回归)
|
|
480
|
-
|
|
599
|
+
let mainFiles = resolveMainChangedFiles(cwd, changeName)
|
|
600
|
+
|
|
601
|
+
// 并入 worktree 未提交改动(坑 module-subset-zero-hit-uncommitted,2026-08-21 实证:
|
|
602
|
+
// 子代理默认不 commit,真实改动全在 worktree working-tree——只看 base..HEAD commit diff
|
|
603
|
+
// 时 module 映射 0 命中直接跳过(frontend/** 变更未命中 frontend 模块)。与
|
|
604
|
+
// generateTaskReviewDrafts 的并入口径同源:meta.worktreePath 下 status --porcelain 文件
|
|
605
|
+
// (排除 .sillyspec/ 运行时产物)。opt-in(默认关):d drafts 有自己的并入点,避免双并。
|
|
606
|
+
if (includeWorkingTree && mainFiles !== null) {
|
|
607
|
+
try {
|
|
608
|
+
const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
|
|
609
|
+
if (changeName && existsSync(metaPath)) {
|
|
610
|
+
const meta = JSON.parse(readFileSync(metaPath, 'utf8'))
|
|
611
|
+
const wtGitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
|
|
612
|
+
? meta.worktreePath
|
|
613
|
+
: cwd
|
|
614
|
+
const wtStatus = gitQuiet(wtGitDir, ['status', '--porcelain'], { timeout: 30000, trim: false })
|
|
615
|
+
const wtFiles = String(wtStatus || '').split('\n')
|
|
616
|
+
.map(l => l.slice(3).trim().split(' -> ').pop() || '')
|
|
617
|
+
.map(p => p.replace(/^"|"$/g, '').replace(/\\/g, '/'))
|
|
618
|
+
.filter(p => p && p !== '.sillyspec' && !p.startsWith('.sillyspec/'))
|
|
619
|
+
if (wtFiles.length > 0) {
|
|
620
|
+
mainFiles = [...new Set([...(mainFiles || []), ...wtFiles])]
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
} catch { /* working-tree 并入失败退回 commit diff 口径(fail-open) */ }
|
|
624
|
+
}
|
|
481
625
|
|
|
482
626
|
// 无 ctx / ctx 无跨仓 entry → 主仓 diff 即结果(零回归)
|
|
483
627
|
if (!ctx || typeof ctx.repos !== 'object' || ctx.repos === null) return mainFiles
|
|
@@ -581,11 +725,15 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
|
|
|
581
725
|
let modulesPresent = false
|
|
582
726
|
let hitCount = 0
|
|
583
727
|
let hits = []
|
|
728
|
+
let lastChangedFiles = [] // 0 命中诊断用(diff 文件样例可见性,坑 module-path-layout-mismatch)
|
|
584
729
|
if (strategy === 'module') {
|
|
585
730
|
const modules = extractModules(yamlText)
|
|
586
731
|
if (modules) {
|
|
587
732
|
modulesPresent = true
|
|
588
|
-
|
|
733
|
+
// includeWorkingTree(坑 module-subset-zero-hit-uncommitted):子代理不 commit 的改动
|
|
734
|
+
// 也参与 module 命中判定,0 命中跳过不再误伤 worktree 未提交的真实变更
|
|
735
|
+
const changedFiles = resolveVerifyChangedFiles(cwd, changeName, null, { includeWorkingTree: true })
|
|
736
|
+
lastChangedFiles = Array.isArray(changedFiles) ? changedFiles : []
|
|
589
737
|
if (changedFiles === null) {
|
|
590
738
|
hitCount = -1 // git 不可用 / 非仓库
|
|
591
739
|
} else {
|
|
@@ -602,13 +750,24 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = nul
|
|
|
602
750
|
} else if (action === 'module-zero-hit-skip') {
|
|
603
751
|
// module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
|
|
604
752
|
// 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
|
|
753
|
+
// 诊断可见性(坑 module-path-layout-mismatch,2026-08-22 实证:0 命中「靠第一次跑过的
|
|
754
|
+
// 记录兜底」——为何没命中完全黑箱):落 modules 配置 path vs diff 文件样例,配置前缀
|
|
755
|
+
// 对不上(如 packages/frontend vs frontend/)一眼可见
|
|
756
|
+
const modules_ = extractModules(yamlText) || {}
|
|
757
|
+
const diagLines = [
|
|
758
|
+
`已配置 modules(${Object.keys(modules_).length} 个): ${Object.entries(modules_).map(([k, m]) => `${k}→${m.path}`).join('、') || '(无)'}`,
|
|
759
|
+
`本次 diff(${lastChangedFiles.length} 个文件,前 5): ${lastChangedFiles.slice(0, 5).join(', ') || '(空)'}`,
|
|
760
|
+
]
|
|
761
|
+
console.warn(`⚠️ 模块 0 命中诊断(对照 path 前缀与 diff 布局是否一致,如 packages/<name> vs <name>/):`)
|
|
762
|
+
for (const l of diagLines) console.warn(` ${l}`)
|
|
605
763
|
mainResult = {
|
|
606
764
|
status: 'skipped',
|
|
607
765
|
command: null,
|
|
608
766
|
exitCode: null,
|
|
609
767
|
durationMs: null,
|
|
610
768
|
outputTail: null,
|
|
611
|
-
reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。'
|
|
769
|
+
reason: 'test_strategy: module 但本次变更未命中任何已配置 modules(0 命中)。为避免回退到注定超时/含预存失败的全量 commands.test,CLI 未自动跑全量——据 verify-result.md 自报告判定测试。若需全量覆盖,显式设 test_strategy: full。' +
|
|
770
|
+
` 诊断:${diagLines.join(';')}`,
|
|
612
771
|
resultPath: null,
|
|
613
772
|
mode: 'module-zero-hit',
|
|
614
773
|
fallbackReason: null,
|
|
@@ -794,6 +953,53 @@ function mergeResultInfo(mainResult, crossResults) {
|
|
|
794
953
|
/**
|
|
795
954
|
* 全量跑 commands.test(现有逻辑,brownfield 行为不变)。
|
|
796
955
|
*/
|
|
956
|
+
/**
|
|
957
|
+
* 从测试命令文本提取疑似服务端口(坑 verify-devserver-port-race,2026-08-22 实证:CLI 全量
|
|
958
|
+
* 对账与用户自留 dev server 资源竞争——端口被占导致测试失败,差点误报 FAIL 成代码问题)。
|
|
959
|
+
* 认 --port=N / --port N / PORT=N。
|
|
960
|
+
* @param {string} cmd
|
|
961
|
+
* @returns {number[]}
|
|
962
|
+
*/
|
|
963
|
+
function extractPortsFromCommand(cmd) {
|
|
964
|
+
const s = String(cmd || '')
|
|
965
|
+
const ports = new Set()
|
|
966
|
+
for (const m of s.matchAll(/--port[=\s]+(\d{2,5})/gi)) ports.add(Number(m[1]))
|
|
967
|
+
for (const m of s.matchAll(/\bPORT=(\d{2,5})/gi)) ports.add(Number(m[1]))
|
|
968
|
+
return [...ports]
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
/**
|
|
972
|
+
* 端口占用探测(同步 spawnSync node 试连——verify 实测是同步 execSync 流,无法 await)。
|
|
973
|
+
* @param {number} port
|
|
974
|
+
* @returns {boolean} true=已被占用(疑似自留 dev server/长驻服务在跑)
|
|
975
|
+
*/
|
|
976
|
+
function isPortOccupiedSync(port) {
|
|
977
|
+
try {
|
|
978
|
+
const { spawnSync } = require('node:child_process')
|
|
979
|
+
const r = spawnSync(process.execPath, ['-e',
|
|
980
|
+
`const n=require('node:net');const s=n.connect(${port},'127.0.0.1',()=>{console.log('Y');s.end()});s.on('error',()=>console.log('N'));setTimeout(()=>{console.log('N');process.exit(0)},1500)`],
|
|
981
|
+
{ encoding: 'utf8', timeout: 4000 })
|
|
982
|
+
return (r.stdout || '').trim().endsWith('Y')
|
|
983
|
+
} catch { return false }
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* 实测前资源竞争预警(坑 verify-devserver-port-race):测试命令涉及的端口已被占用 →
|
|
988
|
+
* 显著 warn「疑似自留 dev server」+ 建议停服务重跑——防把资源竞争误判成代码问题报 FAIL。
|
|
989
|
+
* best-effort:探测失败静默。
|
|
990
|
+
*/
|
|
991
|
+
function warnPortRaceBeforeRun(command) {
|
|
992
|
+
try {
|
|
993
|
+
const ports = extractPortsFromCommand(command)
|
|
994
|
+
for (const p of ports) {
|
|
995
|
+
if (isPortOccupiedSync(p)) {
|
|
996
|
+
console.warn(`⚠️ 端口 ${p} 已被占用(测试命令 ${command.slice(0, 50)}… 引用)——疑似你自留的 dev server/长驻服务。`)
|
|
997
|
+
console.warn(` 测试若因此失败(端口冲突/EADDRINUSE),是资源竞争而非代码问题——停掉占用服务(或换端口)后重跑 verify 再定论,勿直接报 FAIL。`)
|
|
998
|
+
}
|
|
999
|
+
}
|
|
1000
|
+
} catch { /* 探测失败不阻断实测 */ }
|
|
1001
|
+
}
|
|
1002
|
+
|
|
797
1003
|
function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason = null, knownFailures = [] }) {
|
|
798
1004
|
const command = extractTestCommand(yamlText)
|
|
799
1005
|
|
|
@@ -817,6 +1023,7 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
817
1023
|
let exitCode = 0
|
|
818
1024
|
let output = ''
|
|
819
1025
|
let reason = null
|
|
1026
|
+
warnPortRaceBeforeRun(command)
|
|
820
1027
|
try {
|
|
821
1028
|
output = execSync(command, {
|
|
822
1029
|
cwd,
|
|
@@ -831,6 +1038,11 @@ function runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fa
|
|
|
831
1038
|
reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
|
|
832
1039
|
? `测试命令超时(>${TEST_TIMEOUT_MS / 1000}s)`
|
|
833
1040
|
: `测试命令退出码 ${exitCode}`
|
|
1041
|
+
// 资源竞争鉴别提示(坑 verify-devserver-port-race):EADDRINUSE/端口占用类失败极可能是
|
|
1042
|
+
// 自留 dev server 竞争而非代码问题——输出里明示鉴别路径,防误报 FAIL
|
|
1043
|
+
if (/EADDRINUSE|address already in use|端口.*被占用|port.*already/i.test(output)) {
|
|
1044
|
+
reason += '。⚠️ 输出含端口占用信号(EADDRINUSE)——极可能是与你自留 dev server 的资源竞争而非代码问题:停掉占用服务后重跑 verify 再定论'
|
|
1045
|
+
}
|
|
834
1046
|
}
|
|
835
1047
|
const durationMs = Date.now() - startedAt
|
|
836
1048
|
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
@@ -873,6 +1085,11 @@ function runModuleSubset({ cwd, specBase, changeName, hits, knownFailures = [] }
|
|
|
873
1085
|
const reason = status === 'passed'
|
|
874
1086
|
? null
|
|
875
1087
|
: `模块子集测试失败:${perModule.filter(r => r.status === 'failed').map(r => r.name).join(', ')}`
|
|
1088
|
+
// 失败模块的 reason 明细透传(坑 verify-devserver-port-race:EADDRINUSE 资源竞争鉴别
|
|
1089
|
+
// 提示在 runOneModule 的 reason 里,不透传会被顶层 reason 吞掉)
|
|
1090
|
+
+ (perModule.filter(r => r.status === 'failed' && r.reason).some(r => /EADDRINUSE|资源竞争/.test(r.reason))
|
|
1091
|
+
? '。' + perModule.filter(r => r.status === 'failed' && r.reason && /EADDRINUSE|资源竞争/.test(r.reason)).map(r => r.reason).join(';')
|
|
1092
|
+
: '')
|
|
876
1093
|
|
|
877
1094
|
const result = {
|
|
878
1095
|
status,
|
package/src/worktree-apply.js
CHANGED
|
@@ -596,7 +596,10 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
596
596
|
result.patchPath = patchPath;
|
|
597
597
|
|
|
598
598
|
try {
|
|
599
|
-
|
|
599
|
+
// patch 以 Buffer 聚合:git diff --binary 对二进制/非 UTF-8 文件输出任意字节序列,
|
|
600
|
+
// 按 utf8 string 拼接会破坏 NUL 字节成 U+FFFD → corrupt patch(git-helper.js git() 注释
|
|
601
|
+
// 同款坑;worktree.js 的 diff 采集已是 encoding:'buffer' 正确写法)。
|
|
602
|
+
const patchParts = [];
|
|
600
603
|
|
|
601
604
|
// 分 tracked 变更和 untracked 新文件生成 patch
|
|
602
605
|
// 批量化:一次 ls-tree(deliverableBase tree 中存在的文件)+ 一次 ls-files(index 中存在的文件)
|
|
@@ -615,32 +618,33 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
615
618
|
|
|
616
619
|
// tracked 文件:git diff patchBase(patch 生成锚点,默认 merge-base)
|
|
617
620
|
// 数组形式,文件名逐个展开为独立 argv,不经 shell;
|
|
618
|
-
//
|
|
621
|
+
// encoding:'buffer' 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时——原裸 execSync 无 timeout
|
|
619
622
|
if (trackedFiles.length > 0) {
|
|
620
|
-
|
|
623
|
+
patchParts.push(git(
|
|
621
624
|
worktreePath,
|
|
622
625
|
['diff', '--binary', patchBase, '--', ...trackedFiles],
|
|
623
|
-
{
|
|
624
|
-
);
|
|
626
|
+
{ encoding: 'buffer', timeout: 60000 }
|
|
627
|
+
));
|
|
625
628
|
}
|
|
626
629
|
|
|
627
630
|
// untracked 新文件:git add 到 index,git diff --cached,然后 reset(均数组形式,文件名逐个展开)
|
|
628
631
|
if (untrackedPatchFiles.length > 0) {
|
|
629
632
|
git(worktreePath, ['add', '--', ...untrackedPatchFiles]);
|
|
630
633
|
try {
|
|
631
|
-
//
|
|
632
|
-
|
|
634
|
+
// encoding:'buffer' 保留二进制补丁原样,timeout 放大到 60s 防大 diff 超时
|
|
635
|
+
patchParts.push(git(
|
|
633
636
|
worktreePath,
|
|
634
637
|
['diff', '--binary', '--cached', '--', ...untrackedPatchFiles],
|
|
635
|
-
{
|
|
636
|
-
);
|
|
638
|
+
{ encoding: 'buffer', timeout: 60000 }
|
|
639
|
+
));
|
|
637
640
|
} finally {
|
|
638
641
|
// 重置 index(不保留 staged 状态)
|
|
639
642
|
gitQuiet(worktreePath, ['reset', 'HEAD', '--', ...untrackedPatchFiles]);
|
|
640
643
|
}
|
|
641
644
|
}
|
|
642
645
|
|
|
643
|
-
|
|
646
|
+
const patchContent = Buffer.concat(patchParts);
|
|
647
|
+
if (patchContent.length === 0) {
|
|
644
648
|
// patch 为空(清单中部分文件可能没实际变更)
|
|
645
649
|
result.ok = true;
|
|
646
650
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
@@ -717,6 +721,20 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
717
721
|
}
|
|
718
722
|
|
|
719
723
|
} catch (e) {
|
|
724
|
+
// ENOBUFS 自动降级(坑 apply-spawnsync-enobufs,2026-08-22 实证:大 diff 的 binary patch
|
|
725
|
+
// 超 maxBuffer 抛 ENOBUFS,此前只能 agent 手动 git merge 绕行)——git apply 路径走不通时
|
|
726
|
+
// 自动落 applyByMerge(既有 --merge 兜底,语义同为三方合并)
|
|
727
|
+
if (/ENOBUFS/i.test(String(e.message || e.code || ''))) {
|
|
728
|
+
result.warnings = (result.warnings || []).concat(
|
|
729
|
+
[`patch 生成超出进程缓冲区(ENOBUFS,超大 diff)——自动降级 git merge 路径应用`]
|
|
730
|
+
);
|
|
731
|
+
try {
|
|
732
|
+
return applyByMerge(result, changeName, projectRoot, wm);
|
|
733
|
+
} catch (mergeErr) {
|
|
734
|
+
result.errors.push(`ENOBUFS 降级 merge 也失败: ${mergeErr.message}`);
|
|
735
|
+
return result;
|
|
736
|
+
}
|
|
737
|
+
}
|
|
720
738
|
result.errors.push(`patch 生成/应用异常: ${e.message}`);
|
|
721
739
|
return result;
|
|
722
740
|
} finally {
|