sillyspec 3.32.4 → 3.32.5

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.
@@ -27,56 +27,117 @@
27
27
  */
28
28
 
29
29
  import { existsSync, readFileSync, readdirSync, mkdirSync } from 'fs';
30
- import { join } from 'path';
30
+ import { join, dirname, resolve as resolvePath, isAbsolute, relative as relativePath } from 'path';
31
31
  import { git, gitQuiet } from './git-helper.js';
32
32
  import { writeAtomicSync } from './fs-atomic.js';
33
33
  import { WorktreeManager, unlinkNodeModulesLinks, safeRemoveWorktreeDir } from './worktree.js';
34
34
  import { provisionDeps } from './worktree-deps.js';
35
35
  import { aggregateDeclaredRepos } from './run/shared.js';
36
- import { parseRepoRegistry } from './stages/plan-postcheck.js';
36
+ import { parseRepoRegistry, parseRepoWorktreePlacements } from './stages/plan-postcheck.js';
37
+ import {
38
+ crossWorktreePath as _placementCrossPath,
39
+ crossWorktreeDirName,
40
+ resolveCrossWorktreePath,
41
+ resolveCrossWorktreePathCandidates,
42
+ readCrossPlacementConfig,
43
+ resolvePlacementRoot,
44
+ readPlacementRegistry,
45
+ updatePlacementRegistry,
46
+ removePlacementRegistryEntry,
47
+ sweepPlacementRegistry,
48
+ isWslSplit,
49
+ } from './cross-placement.js';
37
50
 
38
51
  const META_FILE = 'meta.json';
39
52
  const BRANCH_PREFIX = 'sillyspec/';
40
53
 
41
54
  /**
42
- * 跨仓 worktree 路径(唯一路径公式;multi-repo-context.js 的 meta 只读逻辑与此同步)。
55
+ * 跨仓 worktree 路径(唯一路径公式;实现已下沉 cross-placement 叶子模块——multi-repo-context.js
56
+ * 经该叶子模块共用,不再与 worktree-cross 经 run/shared 成环)。
57
+ * placementRoot 可选(坑 cross-wt-toolchain-split 落位配置):给定时时 <placementRoot>/<change>--<repoKey>。
43
58
  * @param {string} specBase 主仓 spec 根(<主仓>/.sillyspec)
44
59
  * @param {string} changeName 变更名
45
60
  * @param {string} repoKey 跨仓 repo 键(local.yaml repos: 段的 key)
61
+ * @param {string|null} [placementRoot] 自定义落位根(null/缺省走默认公式——三参调用零回归)
46
62
  */
47
- export function crossWorktreePath(specBase, changeName, repoKey) {
48
- return join(specBase, '.runtime', 'worktrees', `${changeName}--${repoKey}`);
63
+ export function crossWorktreePath(specBase, changeName, repoKey, placementRoot = null) {
64
+ return _placementCrossPath(specBase, changeName, repoKey, placementRoot);
49
65
  }
50
66
 
51
67
  /**
52
68
  * 读跨仓 worktree meta(不存在/损坏返 null = legacy 直写模式)。
69
+ * 寻址经 resolveCrossWorktreePath(注册表优先、公式兜底)——worktree 挪到 crossPlacement
70
+ * 自定义落位后 meta 仍可达(坑 cross-wt-toolchain-split)。
53
71
  * @returns {object|null}
54
72
  */
55
73
  export function getCrossWorktreeMeta(specBase, changeName, repoKey) {
56
- const metaPath = join(crossWorktreePath(specBase, changeName, repoKey), META_FILE);
57
- if (!existsSync(metaPath)) return null;
58
- try {
59
- return JSON.parse(readFileSync(metaPath, 'utf8'));
60
- } catch {
61
- return null;
74
+ // repoRoot 从 repos 注册表解析(无 repoKey 注册/解析失败 → null,候选列表少一级)
75
+ const mainRoot = dirname(specBase);
76
+ const repoRoot = parseRepoRegistry(readLocalYamlText(mainRoot)).get(repoKey) || null;
77
+ const resolvedRoot = repoRoot && !isAbsolute(repoRoot) ? resolvePath(mainRoot, repoRoot) : repoRoot;
78
+ // 寻址候选逐级探测:注册表 → 仓内新公式 → 主仓 specBase 旧公式(legacy 存量兜底)
79
+ for (const dir of resolveCrossWorktreePathCandidates(specBase, changeName, repoKey, resolvedRoot)) {
80
+ const metaPath = join(dir, META_FILE);
81
+ if (!existsSync(metaPath)) continue; // 未命中 → 下一候选
82
+ try {
83
+ const m = JSON.parse(readFileSync(metaPath, 'utf8'));
84
+ return m && m.isCross ? m : null; // 非跨仓/损坏 = 命中但无效
85
+ } catch {
86
+ return null;
87
+ }
62
88
  }
89
+ return null;
63
90
  }
64
91
 
65
92
  /**
66
- * 列出某变更已建的全部跨仓 worktree meta(扫描 worktrees 目录下 `<change>--*` 且 isCross 的 meta)。
93
+ * 列出某变更已建的全部跨仓 worktree meta(双源:worktrees 目录 `<change>--*` 扫描 ∪ 落位
94
+ * 注册表条目——坑 cross-wt-toolchain-split:worktree 挪到 crossPlacement 自定义目录后不在
95
+ * 默认存储目录下,纯目录扫描盲区)。注册表只提供位置:条目指向处须读到可解析且 isCross 的
96
+ * meta.json 才入列(与目录扫描同判据——不合成无 meta 条目,消费方 gates.js 解引用
97
+ * cm.depsStatus 零 TypeError 面)。按 repoKey 去重(注册表与目录扫描可能同指一处)。
67
98
  * @returns {Array<{repoKey: string, meta: object}>}
68
99
  */
69
100
  export function listCrossWorktreeMetas(specBase, changeName) {
70
101
  const base = join(specBase, '.runtime', 'worktrees');
71
- if (!existsSync(base)) return [];
102
+ const candidates = []; // {dir}
103
+ const scanDir = (dir) => {
104
+ if (!existsSync(dir)) return;
105
+ try {
106
+ for (const name of readdirSync(dir)) {
107
+ if (!name.startsWith(`${changeName}--`)) continue;
108
+ candidates.push(join(dir, name));
109
+ }
110
+ } catch { /* 目录不可读跳过 */ }
111
+ };
112
+ // 源① 主仓 specBase 旧默认(legacy-main-specbase 存量)
113
+ scanDir(base);
114
+ // 源② 仓内新默认(<repoRoot>/.sillyspec/.runtime/worktrees——按 local.yaml repos 注册表枚举各仓)
115
+ const mainRoot = dirname(specBase);
116
+ const registryYaml = readLocalYamlText(mainRoot);
117
+ for (const repoRoot of parseRepoRegistry(registryYaml).values()) {
118
+ const root = isAbsolute(repoRoot) ? repoRoot : resolvePath(mainRoot, repoRoot);
119
+ scanDir(join(root, '.sillyspec', '.runtime', 'worktrees'));
120
+ }
121
+ // 源③ 注册表(注册表读取 fail-open 返回 {}——损坏降级纯目录扫描)
122
+ const registry = readPlacementRegistry(specBase);
123
+ const prefix = `${changeName}--`;
124
+ for (const [key, entry] of Object.entries(registry)) {
125
+ if (!key.startsWith(prefix)) continue;
126
+ if (entry && typeof entry.worktreePath === 'string' && entry.worktreePath) {
127
+ candidates.push(entry.worktreePath);
128
+ }
129
+ }
72
130
  const out = [];
73
- for (const name of readdirSync(base)) {
74
- if (!name.startsWith(`${changeName}--`)) continue;
75
- const metaPath = join(base, name, META_FILE);
76
- if (!existsSync(metaPath)) continue;
131
+ const seen = new Set();
132
+ for (const dir of candidates) {
133
+ const metaPath = join(dir, META_FILE);
134
+ if (!existsSync(metaPath)) continue; // 无 meta 不入列(含注册表悬挂键——sweep 前的过渡态)
77
135
  try {
78
136
  const meta = JSON.parse(readFileSync(metaPath, 'utf8'));
79
- if (meta && meta.isCross && meta.repoKey) out.push({ repoKey: meta.repoKey, meta });
137
+ if (meta && meta.isCross && meta.repoKey && !seen.has(meta.repoKey)) {
138
+ seen.add(meta.repoKey);
139
+ out.push({ repoKey: meta.repoKey, meta });
140
+ }
80
141
  } catch { /* 损坏 meta 跳过(cleanup 阶段仍按目录处理) */ }
81
142
  }
82
143
  return out;
@@ -105,17 +166,55 @@ function aggregateCrossRepoKeys(specBase, changeName) {
105
166
  return aggregateDeclaredRepos(planContent).filter(k => k !== 'main');
106
167
  }
107
168
 
169
+ /**
170
+ * 读主仓 local.yaml 文本(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
171
+ * @returns {string}
172
+ */
173
+ function readLocalYamlText(cwd) {
174
+ const p = join(cwd, '.sillyspec', 'local.yaml');
175
+ if (!existsSync(p)) return '';
176
+ try { return readFileSync(p, 'utf8') } catch { return '' }
177
+ }
178
+
179
+ /** worktreePath 是否落在主仓 specBase 旧默认(legacy-main-specbase 形态——该形态不写注册表,公式可寻址) */
180
+ function isLegacyMainSpecbasePlacement(worktreePath, specBase) {
181
+ try {
182
+ return resolvePath(dirname(worktreePath)) === resolvePath(join(specBase, '.runtime', 'worktrees'));
183
+ } catch { return false }
184
+ }
185
+
186
+ /**
187
+ * 仓内落位的 untracked 保障(坑 cross-wt-repo-local-placement):.git/info/exclude 幂等追加
188
+ * 条目——本仓生效、不进版本库(不碰用户 .gitignore 工作区文件、无 pull 冲突面)。创建 worktree
189
+ * 前调用,worktree 目录一出现即被 git 忽略。追加失败(只读仓等)仅 warn 不阻断——最坏回落
190
+ * 「worktree 成 untracked 噪音」,apply 侧清单校验本就过滤。
191
+ * @param {string} repoRoot 跨仓仓根
192
+ * @param {string} entry exclude 条目(如 '.sillyspec/')
193
+ */
194
+ function ensureRepoLocalExclude(repoRoot, entry) {
195
+ try {
196
+ const excludePath = join(repoRoot, '.git', 'info', 'exclude');
197
+ let content = '';
198
+ if (existsSync(excludePath)) {
199
+ try { content = readFileSync(excludePath, 'utf8') } catch { content = '' }
200
+ }
201
+ const entryRe = new RegExp(`^${entry.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}$`, 'm');
202
+ if (entryRe.test(content)) return; // 幂等:已有条目不动
203
+ const sep = content && !content.endsWith('\n') ? '\n' : '';
204
+ const stamp = `${sep}# sillyspec cross-repo worktree placement (auto)\n${entry}\n`;
205
+ mkdirSync(join(repoRoot, '.git', 'info'), { recursive: true });
206
+ writeAtomicSync(excludePath, content + stamp);
207
+ } catch (e) {
208
+ console.warn(`⚠️ 跨仓仓 .git/info/exclude 追加失败(${e.message})——worktree 目录可能成 untracked 噪音,apply 清单校验仍会过滤,建议手工补 ignore。`);
209
+ }
210
+ }
211
+
108
212
  /**
109
213
  * 读主仓 local.yaml 的 repos: 段(路径口径与 run/shared.js readLocalYamlRaw 一致:cwd/.sillyspec/local.yaml)。
110
214
  * @returns {Map<string,string>}
111
215
  */
112
216
  function readRepoRegistry(cwd) {
113
- const p = join(cwd, '.sillyspec', 'local.yaml');
114
- let text = '';
115
- if (existsSync(p)) {
116
- try { text = readFileSync(p, 'utf8') } catch { text = '' }
117
- }
118
- return parseRepoRegistry(text);
217
+ return parseRepoRegistry(readLocalYamlText(cwd));
119
218
  }
120
219
 
121
220
  /**
@@ -132,6 +231,11 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
132
231
  const base = specBase || join(cwd, '.sillyspec');
133
232
  const keys = aggregateCrossRepoKeys(base, changeName);
134
233
  const registry = readRepoRegistry(cwd);
234
+ // 落位配置双源(坑 cross-wt-toolchain-split 配置简化,2026-10-10 用户反馈):推荐 repos 条目
235
+ // 内联(repos.<key>: {path, worktree}——注册与落位一处声明);worktree.crossPlacement 段兼容
236
+ // 保留作 legacy 读面。优先级:repos.<key>.worktree > worktree.crossPlacement.<key> > 默认公式。
237
+ const inlinePlacements = parseRepoWorktreePlacements(readLocalYamlText(cwd));
238
+ const placementCfg = readCrossPlacementConfig(cwd);
135
239
  const created = [];
136
240
  const reused = [];
137
241
  const skippedLegacy = [];
@@ -156,9 +260,36 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
156
260
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
157
261
  }
158
262
 
159
- const worktreePath = crossWorktreePath(base, changeName, key);
263
+ // 落位解析(FR-01/FR-02):显式(repos.<key>.worktree 内联 > crossPlacement.<key> legacy)>
264
+ // 新默认=仓内 <repoRoot>/.sillyspec/.runtime/worktrees(坑 cross-wt-repo-local-placement:
265
+ // 天然同盘同文件系统,WSL 主仓+Windows 盘跨仓的工具链分裂自动消解——用户 2026-10-10 提议,
266
+ // 对齐主仓 worktree 的同构形态)。旧默认(主仓 specBase 下)降 legacy 兜底(resolve/扫描
267
+ // 仍认,存量 worktree 全链可达)。
268
+ // 显式配置指向仓根内维持拒绝(手配仓内=误配——untracked 噪音面);新默认仓内合法,untracked
269
+ // 面由 ensureRepoLocalExclude 的 .git/info/exclude 保障(不动用户 .gitignore、不进版本库)。
270
+ const rawPlacement = inlinePlacements.get(key) || placementCfg.get(key) || null;
271
+ let placementRoot = null;
272
+ let placementMode = 'repo-local';
273
+ if (rawPlacement) {
274
+ placementRoot = resolvePlacementRoot(rawPlacement, cwd);
275
+ placementMode = inlinePlacements.has(key) ? 'explicit-inline' : 'explicit-legacy';
276
+ const rel = relativePath(resolvePath(repoRoot), placementRoot);
277
+ if (rel === '' || (!rel.startsWith('..') && !isAbsolute(rel))) {
278
+ throw new Error(
279
+ `跨仓 repo "${key}" 的落位配置(${placementRoot})落在该仓仓根(${repoRoot})之内——` +
280
+ `显式落位应选该仓之外的目录(仓内落位请删掉该配置走默认)。重跑 execute 前修正配置。`
281
+ );
282
+ }
283
+ }
284
+ const worktreePath = crossWorktreePath(base, changeName, key, placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees'));
160
285
  const branch = BRANCH_PREFIX + changeName;
161
286
 
287
+ // 仓内落位的 untracked 保障:.git/info/exclude 幂等追加 .sillyspec/(本仓生效、不进版本库、
288
+ // 不碰用户 .gitignore)。创建 worktree 前落盘——worktree 目录一出现即被忽略。
289
+ if (!rawPlacement) {
290
+ ensureRepoLocalExclude(repoRoot, '.sillyspec/');
291
+ }
292
+
162
293
  // base 快照:跨仓仓当前 HEAD(与主仓 create 的默认 base 语义一致)
163
294
  let baseHash;
164
295
  try {
@@ -175,13 +306,27 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
175
306
  ` ③ 换变更名重跑。`
176
307
  );
177
308
  }
178
- mkdirSync(join(base, '.runtime', 'worktrees'), { recursive: true });
309
+ if (rawPlacement) {
310
+ mkdirSync(placementRoot, { recursive: true });
311
+ } else {
312
+ mkdirSync(join(repoRoot, '.sillyspec', '.runtime', 'worktrees'), { recursive: true });
313
+ }
179
314
  try {
180
315
  git(repoRoot, ['worktree', 'add', worktreePath, '-b', branch, baseHash], { timeout: 120000 });
181
316
  } catch (e) {
182
317
  throw new Error(`跨仓仓 ${key} worktree 创建失败(${worktreePath}):${e.stderr || e.message}`);
183
318
  }
184
319
 
320
+ // WSL 跨文件系统分裂警告(FR-03,advisory 不阻断——坑 cross-wt-toolchain-split:主仓在
321
+ // WSL /root、跨仓在 /mnt/<盘>/ Windows 盘时,worktree 默认落 WSL 原生 fs,该仓 Windows
322
+ // 侧工具链(mvn/JDK)够不着,隔离形同虚设——fire-equipment 2026-10-10 实证被迫绕开
323
+ // worktree 直写主干。配置同侧落位后本警告自然消音)。
324
+ if (isWslSplit(repoRoot, worktreePath)) {
325
+ console.warn(`⚠️ 跨仓 ${key}: 工具链分裂风险——仓根在 WSL automount Windows 盘(${repoRoot})而 worktree 落在 WSL 原生文件系统(${worktreePath})。`);
326
+ console.warn(` 该仓若依赖 Windows 侧构建工具链(mvn/JDK/IDE),将无法访问 worktree——2026-10-10 实证此形态下 agent 被迫绕开 worktree 直写主副本。`);
327
+ console.warn(` 建议:local.yaml 配 worktree.crossPlacement.${key} 到该仓可达的目录(如同盘符路径),重跑 execute 前先 sillyspec worktree cleanup ${changeName} --force。`);
328
+ }
329
+
185
330
  const meta = {
186
331
  name_zh: 'worktree 元数据',
187
332
  changeName,
@@ -195,6 +340,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
195
340
  createdAt: new Date().toISOString(),
196
341
  worktreePath,
197
342
  mode: 'worktree',
343
+ placementMode,
198
344
  };
199
345
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
200
346
 
@@ -209,6 +355,12 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
209
355
  console.log(`ℹ️ 跨仓 ${key}: 跳过 dirty baseline checkpoint(跨仓主副本在途改动不进本变更分支——含并行会话外来文件时防污染;锚点 = 跨仓仓 HEAD ${String(baseHash).slice(0, 8)})`);
210
356
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
211
357
 
358
+ // 落位注册表(FR-02):仓内新默认与显式落位都记录位置(新默认位置依赖 repoRoot,读取方
359
+ // 无 repoRoot 上下文时公式兜底不可用——注册表是主寻址);落主仓 specBase 的旧默认不写。
360
+ if (placementRoot || !isLegacyMainSpecbasePlacement(worktreePath, base)) {
361
+ updatePlacementRegistry(base, changeName, key, { worktreePath, placementRoot: placementRoot || join(repoRoot, '.sillyspec', '.runtime', 'worktrees') });
362
+ }
363
+
212
364
  // deps 供给:specBase 传 null —— sniff worktree 自身(主仓 local.yaml 的 project.type/install
213
365
  // 描述的是主仓,跨仓仓类型常不同,如实测 maven 主仓 + nodejs 前端仓,沿用会把 mvn 命令
214
366
  // 打到前端仓上)。失败不阻断(与主仓 create 同语义,doctor --fix 可修)
@@ -229,7 +381,7 @@ export function ensureCrossWorktrees({ cwd, changeName, specBase }) {
229
381
  writeAtomicSync(join(worktreePath, META_FILE), JSON.stringify(meta, null, 2) + '\n');
230
382
  }
231
383
 
232
- created.push({ repoKey: key, worktreePath });
384
+ created.push({ repoKey: key, worktreePath, meta });
233
385
  }
234
386
  return { created, reused, skippedLegacy };
235
387
  }
@@ -255,6 +407,7 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
255
407
  const wtPath = meta.worktreePath || crossWorktreePath(base, changeName, repoKey);
256
408
  if (!existsSync(wtPath)) {
257
409
  try { if (repoRoot) gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch {}
410
+ removePlacementRegistryEntry(base, changeName, repoKey); // 挪位清理后注册表键回收(FR-02)
258
411
  results.push({ repoKey, result: 'skipped', details: ['目录不存在(已清理)'], residual });
259
412
  continue;
260
413
  }
@@ -282,7 +435,15 @@ export function cleanupCrossWorktrees({ cwd, changeName, specBase, force = false
282
435
  try { gitQuiet(repoRoot, ['worktree', 'prune'], { timeout: 30000 }) } catch { residual.push(`git worktree prune 失败(${repoRoot})`) }
283
436
  }
284
437
  details.push(`分支 ${meta.branch} 保留作 review 锚点(确认无需回溯后可 git -C ${repoRoot} branch -D ${meta.branch})`);
438
+ if (residual.length === 0) {
439
+ removePlacementRegistryEntry(base, changeName, repoKey); // cleaned 后注册表键回收(FR-02)
440
+ }
285
441
  results.push({ repoKey, result: residual.length > 0 ? 'partial' : 'cleaned', details, residual });
286
442
  }
443
+ // 差集 sweep(评审 P2 吸收):cleanup 迭代源=listCrossWorktreeMetas 只列有 meta 条目,
444
+ // 「worktree remove 连目录带 meta 同亡、仅注册表键存」的悬挂键够不着——末尾对本变更名下
445
+ // 全部键按「worktreePath 处无 meta.json」差集清(活键不删)。
446
+ const swept = sweepPlacementRegistry(base, changeName);
447
+ if (swept.length > 0) results.push({ repoKey: '(registry-sweep)', result: 'cleaned', details: [`悬挂注册表键已清: ${swept.join('、')}`], residual: [] });
287
448
  return { results };
288
449
  }
@@ -38,21 +38,24 @@ const DEFAULT_TIMEOUT_MS = 300 * 1000;
38
38
  */
39
39
  const ECOSYSTEMS = [
40
40
  {
41
+ // JVM 系(maven/gradle)install=null(坑 maven-provision-runs-tests,2026-10-10 用户实证):
42
+ // 依赖在用户级仓库(~/.m2、~/.gradle),worktree 内无本地产物可供给——根供给诚实落 n/a
43
+ // (deps 门控放行集含 n/a)。旧默认 'mvn -o test' 把跑测试当供给:冷缓存 + 离线 + 工具链
44
+ // 不在 PATH 必败 → depsStatus=failed 卡死 execute --done(agent 被迫手改 meta)。构建/测试
45
+ // 归 task 级验证(mvn compile / gradle build 在任务验收面显式执行);显式 commands.install
46
+ // 配置时仍按白名单执行(inferInstallCommand 优先级不变)。
41
47
  type: 'maven',
42
48
  manifests: ['pom.xml'],
43
49
  marker: null,
44
50
  detect: (d) => existsSync(join(d, 'pom.xml')),
45
- install: () => 'mvn -o test',
51
+ install: null,
46
52
  },
47
53
  {
48
54
  type: 'gradle',
49
55
  manifests: ['build.gradle.kts', 'build.gradle', 'settings.gradle', 'gradle.lockfile'],
50
56
  marker: null,
51
57
  detect: (d) => existsSync(join(d, 'build.gradle')) || existsSync(join(d, 'build.gradle.kts')),
52
- // win32 cmd.exe 跑不了 ./gradlew(体检 BUG-08 同型):优先 gradlew.bat,否则全局 gradle
53
- install: (d) => (process.platform === 'win32'
54
- ? (existsSync(join(d, 'gradlew.bat')) ? 'gradlew.bat test' : 'gradle test')
55
- : './gradlew test'),
58
+ install: null,
56
59
  },
57
60
  {
58
61
  type: 'nodejs',
@@ -210,7 +213,10 @@ export function detectProjectType(worktreePath, specBase) {
210
213
  export function inferInstallCommand(projectType, worktreePath, userInstall) {
211
214
  if (userInstall) return userInstall;
212
215
  const eco = ECOSYSTEMS.find(e => e.type === projectType);
213
- return eco ? eco.install(worktreePath) : null;
216
+ // install:null 表项(maven/gradle——依赖在用户级仓库无 worktree 内供给面)与表外类型同归
217
+ // null → 根供给 n/a(坑 maven-provision-runs-tests)
218
+ if (!eco || typeof eco.install !== 'function') return null;
219
+ return eco.install(worktreePath);
214
220
  }
215
221
 
216
222
  /** 在 worktreePath 创建 node_modules 链接到 mainNodeModules;失败回退 */
@@ -640,10 +646,45 @@ export function provisionDeps(worktreePath, mainCwd, opts = {}) {
640
646
  const lines = String(yamlText || '').replace(/\r\n?/g, '\n').split('\n');
641
647
  const start = lines.findIndex(l => /^repos:\s*(?:#.*)?$/.test(l));
642
648
  if (start === -1) return roots;
649
+ const cleanVal = (v) => String(v || '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '');
643
650
  for (let i = start + 1; i < lines.length; i++) {
644
- if (lines[i].length > 0 && !/\s/.test(lines[i][0]) && lines[i].trim() !== '' && !lines[i].startsWith('#')) break;
645
- const m = lines[i].match(/^\s+([A-Za-z0-9_.\-]+):\s*(\S+)/);
646
- if (m && !m[2].startsWith('#')) roots.push(m[2].replace(/^['"]|['"]$/g, ''));
651
+ const line = lines[i];
652
+ if (line.length > 0 && !/\s/.test(line[0]) && line.trim() !== '' && !line.startsWith('#')) break;
653
+ const m = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
654
+ if (!m) continue;
655
+ const value = (m[2] || '').trim();
656
+ if (value !== '' && !value.startsWith('#')) {
657
+ if (value.startsWith('{') && value.endsWith('}')) {
658
+ // inline 对象条目(repos.<key>: {path: .., worktree: ..})——只收 path 子键
659
+ for (const part of value.slice(1, -1).split(',')) {
660
+ const idx = part.indexOf(':');
661
+ if (idx !== -1 && part.slice(0, idx).trim() === 'path') {
662
+ const p = cleanVal(part.slice(idx + 1));
663
+ if (p) roots.push(p);
664
+ break;
665
+ }
666
+ }
667
+ } else {
668
+ roots.push(cleanVal(value));
669
+ }
670
+ continue;
671
+ }
672
+ // 块式对象条目(value 空):消费子键块,只收 path 子键值(worktree 落位根不是仓根,
673
+ // 混入会放宽越界豁免面——坑 repo-inline-worktree-placement 收口)
674
+ const indent = line.length - line.trimStart().length;
675
+ let j = i + 1;
676
+ for (; j < lines.length; j++) {
677
+ const l = lines[j];
678
+ if (l.trim() === '' || l.trim().startsWith('#')) continue;
679
+ const subIndent = l.length - l.trimStart().length;
680
+ if (subIndent <= indent) break;
681
+ const sub = l.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/);
682
+ if (sub && sub[1] === 'path') {
683
+ const p = cleanVal(sub[2]);
684
+ if (p && !p.startsWith('#')) roots.push(p);
685
+ }
686
+ }
687
+ i = j - 1;
647
688
  }
648
689
  return roots;
649
690
  })();