sillyspec 3.22.8 → 3.23.0
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 +141 -5
- package/src/local-detect.js +110 -0
- package/src/machine-interface.js +471 -0
- package/src/progress.js +110 -5
- package/src/run.js +227 -46
- package/src/stage-contract.js +107 -1
- package/src/stages/brainstorm-auto.js +1 -1
- package/src/stages/brainstorm.js +1 -1
- package/src/stages/quick.js +25 -14
- 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 +104 -1
- package/src/verify-postcheck.js +433 -0
- package/src/worktree-apply.js +19 -4
- package/src/worktree.js +12 -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
|
+
}
|
package/src/worktree-apply.js
CHANGED
|
@@ -34,6 +34,20 @@ function gitQuiet(cwd, args) {
|
|
|
34
34
|
}
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
+
/**
|
|
38
|
+
* 过滤掉 worktree 基础设施文件(非交付物),让 apply 只关心真正的变更产出:
|
|
39
|
+
* - meta.json:worktree 元数据,baseline commit 中被跟踪、working-tree 被 CLI 改写
|
|
40
|
+
* (provisioning→linked 等)。它必须保持 modified(其 baselineCommit 字段是 apply diff
|
|
41
|
+
* 的锚点,保证 baseline overlay 文件不被误判为变更)。若不排除,modified-tracked 的
|
|
42
|
+
* meta.json 会落入 changedFiles,触发「不在 design.md 清单」误判,导致每个 execute 的
|
|
43
|
+
* assess 恒 BLOCKED。
|
|
44
|
+
* - .sillyspec/:变更文档 / 运行时产物,不属于源码交付。
|
|
45
|
+
* 对 modified-tracked(git diff)与 untracked(ls-files --others)一视同仁。
|
|
46
|
+
*/
|
|
47
|
+
export function filterDeliverableFiles(files) {
|
|
48
|
+
return files.filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json');
|
|
49
|
+
}
|
|
50
|
+
|
|
37
51
|
/**
|
|
38
52
|
* 获取文件在 git 中的 blob hash(基于某个 commit/tree)
|
|
39
53
|
* @param {string} cwd - git 工作区路径
|
|
@@ -106,11 +120,12 @@ export function applyWorktree(changeName, { cwd, checkOnly = false } = {}) {
|
|
|
106
120
|
|
|
107
121
|
// untracked 新文件(diffBase 中不存在的文件)
|
|
108
122
|
const untrackedRaw = gitQuiet(worktreePath, `ls-files --others --exclude-standard`);
|
|
109
|
-
const untrackedFiles = untrackedRaw
|
|
110
|
-
? untrackedRaw.split('\n').filter(Boolean).filter(f => !f.startsWith('.sillyspec/') && f !== 'meta.json')
|
|
111
|
-
: [];
|
|
123
|
+
const untrackedFiles = untrackedRaw ? untrackedRaw.split('\n').filter(Boolean) : [];
|
|
112
124
|
|
|
113
|
-
|
|
125
|
+
// 排除 worktree 基础设施文件(meta.json / .sillyspec/,见 filterDeliverableFiles)。
|
|
126
|
+
// 对 modified-tracked(statusFiles)与 untracked 一视同仁——否则 modified 的 meta.json
|
|
127
|
+
// 会被误算入 changedFiles,触发 design.md 清单校验失败(assess 恒 BLOCKED)。
|
|
128
|
+
changedFiles = filterDeliverableFiles([...new Set([...statusFiles, ...untrackedFiles])]);
|
|
114
129
|
} catch (e) {
|
|
115
130
|
result.errors.push(`获取变更文件列表失败: ${e.message}`);
|
|
116
131
|
return result;
|
package/src/worktree.js
CHANGED
|
@@ -453,6 +453,16 @@ export class WorktreeManager {
|
|
|
453
453
|
return { branch: existingMeta.branch, worktreePath: existingMeta.worktreePath, baseHash: existingMeta.baseHash, mode: existingMeta.mode }
|
|
454
454
|
}
|
|
455
455
|
|
|
456
|
+
// 供给 deps(与 native worktree 路径一致)。in-place-fallback 模式漏写 depsStatus 会致
|
|
457
|
+
// enforceDepsGate(run.js)把 undefined 当 unknown 阻断 execute --done,死锁无法推进。
|
|
458
|
+
// 见 docs/sillyspec/execute-inplace-deps-gate.md(2026-07-08 发现)。
|
|
459
|
+
let deps = {}
|
|
460
|
+
try {
|
|
461
|
+
deps = provisionDeps(worktreePath, this.cwd, { specBase: join(this.cwd, '.sillyspec') }) || {}
|
|
462
|
+
} catch (e) {
|
|
463
|
+
deps = { depsStatus: 'failed', depsError: `provisionDeps crashed: ${e.message}` }
|
|
464
|
+
}
|
|
465
|
+
|
|
456
466
|
// 硬规则:禁止 self-overlay(source 和 target 相同时 overlay 必然冲突)
|
|
457
467
|
const resolvedSource = resolve(this.cwd)
|
|
458
468
|
const resolvedTarget = resolve(worktreePath)
|
|
@@ -474,6 +484,7 @@ export class WorktreeManager {
|
|
|
474
484
|
baselineFiles: [],
|
|
475
485
|
baselineCommit: null,
|
|
476
486
|
baselineHash: null,
|
|
487
|
+
...deps,
|
|
477
488
|
}
|
|
478
489
|
if (!existsSync(this.worktreeBase)) mkdirSync(this.worktreeBase, { recursive: true })
|
|
479
490
|
const metaDir = join(this.worktreeBase, name)
|
|
@@ -510,6 +521,7 @@ export class WorktreeManager {
|
|
|
510
521
|
baselineFiles,
|
|
511
522
|
baselineCommit,
|
|
512
523
|
baselineHash,
|
|
524
|
+
...deps,
|
|
513
525
|
};
|
|
514
526
|
|
|
515
527
|
// in-place 模式下 meta 写入 worktreeBase(避免污染主工作区)
|