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.
@@ -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
+ }
@@ -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
- changedFiles = [...new Set([...statusFiles, ...untrackedFiles])];
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(避免污染主工作区)