sillyspec 3.32.3 → 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.
@@ -480,7 +480,7 @@ const QL_ID_RE = /^ql-\d{8}-\d{3}-[0-9a-f]{4}$/
480
480
  * @param {string} specBase .sillyspec 根
481
481
  * @returns {string[]} repoKey 清单
482
482
  */
483
- function loadRegisteredRepoKeys(specBase) {
483
+ export function loadRegisteredRepoKeys(specBase) {
484
484
  try {
485
485
  const p = join(specBase || '', 'local.yaml')
486
486
  if (!specBase || !existsSync(p)) return []
@@ -1214,90 +1214,14 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1214
1214
  }
1215
1215
 
1216
1216
  // —— 计划侧跨仓段分仓真实对账(2026-09-20 scope-audit-cross-repo task-02,design Wave 2)——
1217
- // 旧形态「⊘ 行不在本仓对账面(实际侧只采主仓 git)」升级:plannedEntries 按 e.repo 分组,
1218
- // 每组调 collectRepoActual 共享内核(task-01)在**该仓**取 actual(锚点四级:A reviews-range
1219
- // > B head~1-window > C head-uncommitted-window > degraded),主仓同款三类差集出真实三态行
1220
- // + 行数;degraded 仓(未注册/路径不可达/git 双源失败)退 v1 ⊘ 形态(untouched + crossRepo)
1221
- // + 降级注记进 note,不炸主仓表(D-006 fail-soft)。
1222
- // 行数由本集成层对内核产物跑 collectNumstatByPath(内核不 import scope-audit 防环,评审
1223
- // G3——调用方职责);anchor.base 为 null(B/C 档降级锚)→ numstat 空 Map → degradedStat
1224
- // 该仓根兜底(行数 null 降级档,不出伪数据)。跨仓行过滤口径与主仓 actual 侧一致
1225
- // (filterDeliverableFiles:排 .sillyspec 运行时面/平台自装脚手架),unplanned 行的
1226
- // classifyToolScaffold 软桶与主仓 unplanned 行同款。
1227
- const crossRepoKeys = [...new Set(plannedEntries.map(e => e.repo).filter(Boolean))]
1228
- const crossRepoRows = []
1229
- const crossRepoEntries = []
1230
- if (crossRepoKeys.length > 0) {
1231
- const crossCount = plannedEntries.filter(e => e.repo).length
1232
- notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——已按 local.yaml repos 注册表分仓对账(各仓锚点档见 repos[].anchor)`)
1233
- for (const repoKey of crossRepoKeys) {
1234
- const entries = plannedEntries.filter(e => e.repo === repoKey)
1235
- const repoRows = []
1236
- let repoActual = null
1237
- let repoFailure = null
1238
- try {
1239
- repoActual = collectRepoActual({ repoKey, specBase: sb, cwd, runtimeRoot, changeName })
1240
- } catch (e) {
1241
- repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1242
- }
1243
- if (repoFailure || !repoActual || repoActual.degradedReason) {
1244
- // degraded 仓(内核 fail-soft 产物或调用点异常):v1 ⊘ 形态回退——该组行恒 untouched,
1245
- // 降级原因进 note 与 repos[] 条目(平台消费方可感知),主仓表不受影响。
1246
- const reason = repoFailure
1247
- ? `内核采集异常: ${repoFailure}`
1248
- : (repoActual && repoActual.degradedReason) || '跨仓采集不可用'
1249
- for (const e of entries) {
1250
- repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1251
- }
1252
- notes.push(`跨仓 ${repoKey} 对账降级: ${reason}`)
1253
- crossRepoEntries.push({
1254
- key: repoKey,
1255
- repoPath: repoActual ? repoActual.repoPath : null,
1256
- anchor: repoActual ? repoActual.anchor : { source: 'degraded', base: null, head: null, label: 'degraded' },
1257
- totals: repoTotals(repoRows),
1258
- degraded: true,
1259
- degradedReason: reason,
1260
- })
1261
- crossRepoRows.push(...repoRows)
1262
- continue
1263
- }
1264
- // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1265
- // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1266
- const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1267
- const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1268
- const repoMatched = new Set()
1269
- for (const f of repoFiles) {
1270
- const path = toPosix(f)
1271
- const st = repoStats.get(path) || degradedStat(repoActual.repoPath, path)
1272
- const row = { path, planned: null, additions: st.additions, deletions: st.deletions, kind: st.kind, verdict: 'unplanned', crossRepo: repoKey }
1273
- const entry = entries.find(e => pathMatches(path, e.path))
1274
- if (entry) {
1275
- repoMatched.add(entry.path)
1276
- row.planned = entry.operation || null
1277
- row.verdict = 'planned'
1278
- } else {
1279
- // 工具/平台脚手架软桶(主仓 unplanned 行同款):CLI/平台自装文件不占「计划外」逐条清单
1280
- const facility = classifyToolScaffold(path)
1281
- if (facility) row.facility = facility
1282
- }
1283
- repoRows.push(row)
1284
- }
1285
- // 声明未动:该仓清单文件无实际改动 → 补行(行数 0/0,主仓补行同款;crossRepo 恒带)
1286
- for (const e of entries) {
1287
- if (repoMatched.has(e.path)) continue
1288
- repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1289
- }
1290
- crossRepoEntries.push({
1291
- key: repoKey,
1292
- repoPath: repoActual.repoPath,
1293
- anchor: repoActual.anchor,
1294
- totals: repoTotals(repoRows),
1295
- degraded: false,
1296
- degradedReason: null,
1297
- })
1298
- crossRepoRows.push(...repoRows)
1299
- }
1300
- }
1217
+ // 集成逻辑抽共享导出 reconcileCrossRepoPlan(2026-10-10-cross-repo-patch-freeze D-002@v1):
1218
+ // heavy(本函数)与 thin(flow.js done)同源消费防口径漂移;本处行为等价(rows/repos/notes
1219
+ // 三产物原样并回)+ collectPatch 时 repos[].patch/patchSha256 增量。
1220
+ const crossRecon = reconcileCrossRepoPlan({ cwd, specBase: sb, runtimeRoot, changeName, planEntries: plannedEntries, collectPatch: !!collectPatch })
1221
+ const crossRepoRows = crossRecon.rows
1222
+ const crossRepoEntries = crossRecon.repos
1223
+ for (const n of crossRecon.notes) notes.push(n)
1224
+ const crossRepoKeys = crossRepoEntries.map(e => e.key)
1301
1225
 
1302
1226
  // —— 三态判定 ——
1303
1227
  const rows = []
@@ -1382,6 +1306,127 @@ async function computeFullFlowAudit({ cwd, specBase, changeName, platformOpts, c
1382
1306
  }
1383
1307
  }
1384
1308
 
1309
+ /**
1310
+ * 跨仓对账集成层(2026-10-10-cross-repo-patch-freeze D-002@v1,自 computeFullFlowAudit 内联段
1311
+ * 抽出——heavy(computeFullFlowAudit)与 thin(flow.js done)同源消费防两份口径漂移;复用
1312
+ * collectRepoActual 共享内核(2026-09-20「单一真相源」哲学))。
1313
+ *
1314
+ * planEntries 按 e.repo 分组,每组调 collectRepoActual 在**该仓**取 actual(锚点:A
1315
+ * reviews-range > B' worktree-baseline > B head~1-window > C head-uncommitted-window >
1316
+ * degraded),主仓同款三类差集出真实三态行 + 行数;degraded 仓退 ⊘ 形态(untouched +
1317
+ * crossRepo + degradedReason),不炸调用方表(D-006 fail-soft)。行数由本层对内核产物跑
1318
+ * collectNumstatByPath(内核不 import 本模块防环,评审 G3——调用方职责)。
1319
+ *
1320
+ * collectPatch=true 时每仓增 patch 采集(D-001@v1/D-003@v1):窗口与行数同根同窗——A/B' 档
1321
+ * 锚 hash 为 baseRef;B 档字面 HEAD~1、C 档字面 HEAD(工作树口径含 untracked 自拼 hunk);
1322
+ * buildFrozenPatch 失败或空窗 → patch=null 不出伪件;sha256 走 sha256PatchNormalized(LF 归一,
1323
+ * autocrlf 跨平台一致)。collectPatch=false 不产 patch 两键(additive 契约,既有读方零感知)。
1324
+ *
1325
+ * @param {{ cwd: string, specBase: string, runtimeRoot?: string|null, changeName?: string|null,
1326
+ * planEntries: Array<{path, operation?, repo?}>, collectPatch?: boolean }} args
1327
+ * @returns {{ rows: Array<object>, repos: Array<object>, notes: string[] }}
1328
+ * rows 带 crossRepo 标记(真实三态或 degraded ⊘);repos 条目
1329
+ * { key, repoPath, anchor, totals, degraded, degradedReason, patch?, patchSha256? };
1330
+ * notes 为调用方 note 链的增量行(跨仓计数行 + 降级行)。
1331
+ */
1332
+ export function reconcileCrossRepoPlan({ cwd, specBase, runtimeRoot, changeName, planEntries, collectPatch = false }) {
1333
+ const rows = []
1334
+ const repos = []
1335
+ const notes = []
1336
+ const planned = Array.isArray(planEntries) ? planEntries : []
1337
+ const crossRepoKeys = [...new Set(planned.map(e => e.repo).filter(Boolean))]
1338
+ if (crossRepoKeys.length === 0) return { rows, repos, notes }
1339
+ const crossCount = planned.filter(e => e.repo).length
1340
+ notes.push(`计划侧含 ${crossCount} 个跨仓文件(repo:${crossRepoKeys.join('、')})——已按 local.yaml repos 注册表分仓对账(各仓锚点档见 repos[].anchor)`)
1341
+ for (const repoKey of crossRepoKeys) {
1342
+ const entries = planned.filter(e => e.repo === repoKey)
1343
+ const repoRows = []
1344
+ let repoActual = null
1345
+ let repoFailure = null
1346
+ try {
1347
+ repoActual = collectRepoActual({ repoKey, specBase, cwd, runtimeRoot, changeName })
1348
+ } catch (e) {
1349
+ repoFailure = e && e.message ? String(e.message).split('\n')[0] : String(e)
1350
+ }
1351
+ if (repoFailure || !repoActual || repoActual.degradedReason) {
1352
+ // degraded 仓(内核 fail-soft 产物或调用点异常):⊘ 形态回退——该组行恒 untouched,
1353
+ // 降级原因进 note 与 repos[] 条目(平台消费方可感知),调用方主表不受影响。
1354
+ const reason = repoFailure
1355
+ ? `内核采集异常: ${repoFailure}`
1356
+ : (repoActual && repoActual.degradedReason) || '跨仓采集不可用'
1357
+ for (const e of entries) {
1358
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1359
+ }
1360
+ notes.push(`跨仓 ${repoKey} 对账降级: ${reason}`)
1361
+ repos.push({
1362
+ key: repoKey,
1363
+ repoPath: repoActual ? repoActual.repoPath : null,
1364
+ anchor: repoActual ? repoActual.anchor : { source: 'degraded', base: null, head: null, label: 'degraded' },
1365
+ totals: repoTotals(repoRows),
1366
+ degraded: true,
1367
+ degradedReason: reason,
1368
+ ...(collectPatch ? { patch: null, patchSha256: null } : {}),
1369
+ })
1370
+ rows.push(...repoRows)
1371
+ continue
1372
+ }
1373
+ // 真实三态:该仓 actual(filterDeliverableFiles 过滤,主仓口径)× 该组声明面 pathMatches
1374
+ // 双向容差(glob/目录前缀兼容,design 清单可写 glob)→ planned / unplanned / untouched
1375
+ const repoFiles = [...new Set(filterDeliverableFiles(repoActual.files).filter(Boolean))].map(toPosix).sort()
1376
+ const repoStats = collectNumstatByPath(repoActual.repoPath, repoFiles, { baseRef: repoActual.anchor.base })
1377
+ const repoMatched = new Set()
1378
+ for (const f of repoFiles) {
1379
+ const path = toPosix(f)
1380
+ const st = repoStats.get(path) || degradedStat(repoActual.repoPath, path)
1381
+ const row = { path, planned: null, additions: st.additions, deletions: st.deletions, kind: st.kind, verdict: 'unplanned', crossRepo: repoKey }
1382
+ const entry = entries.find(e => pathMatches(path, e.path))
1383
+ if (entry) {
1384
+ repoMatched.add(entry.path)
1385
+ row.planned = entry.operation || null
1386
+ row.verdict = 'planned'
1387
+ } else {
1388
+ // 工具/平台脚手架软桶(主仓 unplanned 行同款):CLI/平台自装文件不占「计划外」逐条清单
1389
+ const facility = classifyToolScaffold(path)
1390
+ if (facility) row.facility = facility
1391
+ }
1392
+ repoRows.push(row)
1393
+ }
1394
+ // 声明未动:该仓清单文件无实际改动 → 补行(行数 0/0,主仓补行同款;crossRepo 恒带)
1395
+ for (const e of entries) {
1396
+ if (repoMatched.has(e.path)) continue
1397
+ repoRows.push({ path: e.path, planned: e.operation || null, additions: 0, deletions: 0, kind: 'modified', verdict: 'untouched', crossRepo: repoKey })
1398
+ }
1399
+ // patch 采集(D-001/D-003):窗口 = 行数窗口——锚 hash 优先;B/C 档降级锚用字面 ref
1400
+ // (head~1-window → HEAD~1;head-uncommitted-window → HEAD),工作树口径含 untracked。
1401
+ let patch = null
1402
+ let patchSha256 = null
1403
+ if (collectPatch && repoFiles.length > 0) {
1404
+ const patchBase = repoActual.anchor.base
1405
+ || (repoActual.anchor.source === 'head~1-window' ? 'HEAD~1'
1406
+ : repoActual.anchor.source === 'head-uncommitted-window' ? 'HEAD'
1407
+ : null)
1408
+ if (patchBase) {
1409
+ const text = buildFrozenPatch(repoActual.repoPath, repoFiles, { baseRef: patchBase })
1410
+ if (typeof text === 'string' && text.length > 0) {
1411
+ patch = text
1412
+ patchSha256 = sha256PatchNormalized(text)
1413
+ }
1414
+ }
1415
+ }
1416
+ repos.push({
1417
+ key: repoKey,
1418
+ repoPath: repoActual.repoPath,
1419
+ anchor: repoActual.anchor,
1420
+ totals: repoTotals(repoRows),
1421
+ degraded: false,
1422
+ degradedReason: null,
1423
+ ...(collectPatch ? { patch, patchSha256 } : {}),
1424
+ })
1425
+ rows.push(...repoRows)
1426
+ }
1427
+ return { rows, repos, notes }
1428
+ }
1429
+
1385
1430
  /**
1386
1431
  * 变更范围对账纯函数(scope-audit 命令与三处阶段注入的唯一数据源,D-003)。
1387
1432
  *
package/src/spec-sync.js CHANGED
@@ -594,11 +594,29 @@ export function isFollowerSetChanged(specRoot, key, list) {
594
594
  * @param {string} changeName - 当前变更名
595
595
  * @returns {Promise<{synced: number, conflict?: boolean, serverVersions?: object}>}
596
596
  */
597
+ /**
598
+ * 冲突路径 → 变更名归因标签(2026-10-11-sync-conflict-tri-fix FR-03 全树同步入口用):
599
+ * changes/(archive/)?<名>/ 前缀提取,全部路径归因到同一变更 → 用该变更名(冲突文件
600
+ * spec-sync-conflict-<名>.json 与 per-change 流程兼容,resolve/status UX 不变);
601
+ * 跨多变更或无可归因路径 → 'full-tree'(单文件合并裁决,resolve 整体 force-push)。
602
+ */
603
+ function attributeChangeLabel(paths) {
604
+ const names = new Set()
605
+ for (const p of paths) {
606
+ const m = p.match(/^changes\/(?:archive\/)?([^/]+)\//)
607
+ if (m) names.add(m[1])
608
+ }
609
+ return names.size === 1 ? [...names][0] : 'full-tree'
610
+ }
611
+
597
612
  export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
598
613
  if (!platform || !platform.url || !platform.token) {
599
614
  debugLog('[spec-sync] 未连接平台(本地合法状态);跳过 spec 树增量同步');
600
615
  return { synced: 0 };
601
616
  }
617
+ // 全树入口(platform sync 无 --change,2026-10-11-sync-conflict-tri-fix FR-03)
618
+ // changeName=null:日志用 full-tree 标签,冲突归因见下方 conflictLabel。
619
+ const logLabel = changeName || 'full-tree';
602
620
  // 绑定连接类失败噪音闸(sync-noise.js):本目录所有 [spec-sync]/[sync] 失败 warn 的
603
621
  // 跨进程静默窗口 marker 落这里(与 SyncManager 构造器绑定同目录)。
604
622
  try { bindSyncNoiseRoot(join(specRoot, '.runtime')); } catch { /* 直通降级 */ }
@@ -622,7 +640,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
622
640
  // 8 分钟才被后续步骤的同步补上)。失败可见、成功不打扰。
623
641
  // 连接类失败(404=端点未就绪 / 5xx)走噪音闸(sync-noise.js):平台未就绪期每步
624
642
  // 自动同步重刷同一行,淹没门禁输出;其余状态码(4xx 业务态)每轮该看见。
625
- const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
643
+ const line = `[spec-sync] 拉取清单失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
626
644
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
627
645
  else console.warn(line);
628
646
  return { synced: 0 };
@@ -630,7 +648,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
630
648
  const body = await res.json().catch(() => ({}));
631
649
  serverManifest = body.files || {};
632
650
  } catch (err) {
633
- const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
651
+ const line = `[spec-sync] 拉取清单异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
634
652
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
635
653
  return { synced: 0 };
636
654
  }
@@ -671,7 +689,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
671
689
  }
672
690
 
673
691
  if (ops.length === 0) {
674
- debugLog(`[spec-sync] 无差异,跳过同步: ${changeName}`);
692
+ debugLog(`[spec-sync] 无差异,跳过同步: ${logLabel}`);
675
693
  clearStaleTombstoneMarkers(specRoot);
676
694
  return { synced: 0 };
677
695
  }
@@ -688,7 +706,7 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
688
706
  signal: withSignal(30000),
689
707
  });
690
708
  if (!res.ok) {
691
- const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${changeName}`;
709
+ const line = `[spec-sync] 同步请求失败 HTTP ${res.status}(文件树本次未同步,下次自动重试): ${logLabel}`;
692
710
  if (isConnectionClassStatus(res.status)) syncConnectionWarn(line);
693
711
  else console.warn(line);
694
712
  return { synced: 0 };
@@ -719,6 +737,9 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
719
737
  ? { followers: [], real: serverVersions }
720
738
  : partitionConflictPaths(serverVersions, localFiles, baseHashes, lastSyncTs);
721
739
  const realPaths = Object.keys(real).filter((p) => !tombSet.has(p));
740
+ // 全树入口(changeName=null)冲突归因:单一变更 → 该变更名(冲突文件/markers 与
741
+ // per-change 流程同名兼容);跨变更 → 'full-tree' 单文件合并裁决。
742
+ const conflictLabel = changeName || attributeChangeLabel(realPaths);
722
743
 
723
744
  // 纯墓碑形态:版本冲突面为空、拒收全是墓碑 → 不写/不清通用冲突标记(clear 会把
724
745
  // 状态洗成「已消解」误导 status),单独落冲突文件 + 正确恢复指引。
@@ -743,13 +764,13 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
743
764
  // 自愈」连环刷屏。冲突文件在且冲突集未变 = 用户已被告知:重复轮次单行提示(可见但不
744
765
  // 吓人),不重写文件(created_at 稳定,status 红标语义不变);冲突集变化才重写+全幅。
745
766
  // forcePush(resolve --keep-local 刚裁决完重推)不参与去重——需真实判定与文件刷新。
746
- const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, changeName, realPaths);
767
+ const pendingSame = !opts.forcePush && realPaths.length > 0 && isSamePendingSpecConflict(specRoot, conflictLabel, realPaths);
747
768
  if (pendingSame) {
748
769
  if (followers.length > 0) {
749
770
  debugLog(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器: ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
750
771
  }
751
- console.warn(`⚠️ [spec-sync] 变更 ${changeName} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort`);
752
- return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${changeName}.json`), suppressed: true };
772
+ console.warn(`⚠️ [spec-sync] 变更 ${conflictLabel} 存在未决 spec 树冲突(${realPaths.length} 个文件待裁决,本轮非冲突改动已照常同步)。处理:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort`);
773
+ return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${conflictLabel}.json`), suppressed: true };
753
774
  }
754
775
  if (followers.length > 0) {
755
776
  console.warn(`[spec-sync] ${followers.length} 个本地未改动文件自动跟随服务器(正常多端前进,无需人工裁决): ${followers.slice(0, 5).join(', ')}${followers.length > 5 ? ' 等' : ''}`);
@@ -757,18 +778,18 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
757
778
  if (realPaths.length === 0) {
758
779
  writeLastSyncTs(specRoot);
759
780
  writeBaseSnapshot(specRoot, localFiles);
760
- clearSpecConflictMarker(specRoot, changeName);
781
+ clearSpecConflictMarker(specRoot, conflictLabel);
761
782
  noteSyncSuccess();
762
- console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${changeName}`);
783
+ console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${conflictLabel}`);
763
784
  return { synced: Math.max(ops.length - followers.length, 0), conflict: false, autoResolved: followers.length };
764
785
  }
765
786
  let conflictPath = null;
766
787
  try {
767
788
  const runtimeDir = join(specRoot, '.runtime');
768
789
  mkdirSync(runtimeDir, { recursive: true });
769
- conflictPath = join(runtimeDir, `spec-sync-conflict-${changeName}.json`);
790
+ conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
770
791
  writeFileSync(conflictPath, JSON.stringify({
771
- change: changeName,
792
+ change: conflictLabel,
772
793
  kind: 'spec-tree',
773
794
  created_at: new Date().toISOString(),
774
795
  server_versions: real,
@@ -786,20 +807,28 @@ export async function syncSpecTree(specRoot, platform, changeName, opts = {}) {
786
807
  console.warn('');
787
808
  console.warn(`⚠️ [spec-sync] 检测到 spec 树冲突(${realPaths.length} 个文件: ${shownPaths};逐文件服务器版本见冲突详情文件)`);
788
809
  if (tombNote) console.warn(`⚠️ [spec-sync]${tombNote.trim()}`);
789
- console.warn(`⚠️ 处置:sillyspec platform resolve ${changeName} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
810
+ console.warn(`⚠️ 处置:sillyspec platform resolve ${conflictLabel} --keep-local | --take-platform | --abort(冲突详情: ${conflictPath || '(写入失败)'})`);
790
811
  return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath };
791
812
  }
792
813
  noteSyncSuccess();
793
- console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${changeName}`);
814
+ console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${logLabel}`);
794
815
  // 本轮无冲突 = 此前的未决 spec 树冲突已不存在,清残留标记(否则 status 永久红标、
795
- // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)
796
- clearSpecConflictMarker(specRoot, changeName);
816
+ // 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam)。全树入口
817
+ // (changeName=null,2026-10-11-sync-conflict-tri-fix FR-03)按归因清:只清本轮
818
+ // ops 触及变更的标记——未触及变更的未决冲突不因全树绿而误清。
819
+ if (changeName) {
820
+ clearSpecConflictMarker(specRoot, changeName);
821
+ } else {
822
+ for (const name of new Set(ops.map((o) => attributeChangeLabel([o.path])))) {
823
+ if (name !== 'full-tree') clearSpecConflictMarker(specRoot, name);
824
+ }
825
+ }
797
826
  clearStaleTombstoneMarkers(specRoot);
798
827
  writeLastSyncTs(specRoot);
799
828
  writeBaseSnapshot(specRoot, localFiles);
800
829
  return { synced: ops.length };
801
830
  } catch (err) {
802
- const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${changeName}: ${describeSyncError(err)}`;
831
+ const line = `[spec-sync] 同步异常(文件树本次未同步,下次自动重试): ${logLabel}: ${describeSyncError(err)}`;
803
832
  syncConnectionWarn(line); // 网络/超时/熔断均连接类
804
833
  return { synced: 0 };
805
834
  }
@@ -261,6 +261,34 @@ export function detectDuplicateTopKeys(fmText) {
261
261
  * @returns {Map<string, string>} repoKey → absolutePath
262
262
  */
263
263
  export function parseRepoRegistry(yamlText) {
264
+ return _parseRepoEntries(yamlText, 'path')
265
+ }
266
+
267
+ /**
268
+ * repos 段条目的 worktree 子键 → Map<repoKey, 落位根目录>(坑 cross-wt-toolchain-split 配置
269
+ * 简化,2026-10-10 用户反馈:独立 worktree.crossPlacement 段与仓注册分居两处形态重——落位
270
+ * 直接内联在注册条目上)。
271
+ *
272
+ * 只认对象形态条目的 worktree 子键;字符串条目(值即路径)无落位语义不入 Map。清洗规则
273
+ * (CRLF/行内注释/引号)与 parseRepoRegistry 同源。
274
+ * @param {string} yamlText
275
+ * @returns {Map<string, string>} repoKey → 落位根目录(未配置的仓不在 Map)
276
+ */
277
+ export function parseRepoWorktreePlacements(yamlText) {
278
+ return _parseRepoEntries(yamlText, 'worktree')
279
+ }
280
+
281
+ /**
282
+ * repos 段统一解析内核:subkey='path' → 注册表(字符串条目值即 path);subkey='worktree' →
283
+ * 落位内联(仅对象条目)。条目三形态:
284
+ * urgent: /mnt/e/repo # 字符串(值即 path,worktree 不适用)
285
+ * urgent: {path: /mnt/e/repo, worktree: /mnt/e/wt} # inline flow 对象
286
+ * urgent: # 块式对象
287
+ * path: /mnt/e/repo
288
+ * worktree: /mnt/e/wt
289
+ * @returns {Map<string, string>}
290
+ */
291
+ function _parseRepoEntries(yamlText, subkey) {
264
292
  const reg = new Map()
265
293
  if (!yamlText) return reg
266
294
  // CRLF 归一(坑 register-repo-crlf-idempotent-loop,2026-08-23 实证:Windows 下 agent Write
@@ -269,6 +297,7 @@ export function parseRepoRegistry(yamlText) {
269
297
  // 幂等跳过不落盘 → 死循环。同文件 parseAllowedPaths/parseDependsOn/parseBaseCommit 均有此
270
298
  // 归一,本函数是唯一缺口)
271
299
  const lines = String(yamlText).replace(/\r\n?/g, '\n').split('\n')
300
+ const cleanVal = (v) => String(v || '').replace(/\s+#.*$/, '').trim().replace(/^['"]|['"]$/g, '')
272
301
  let startIdx = -1
273
302
  for (let i = 0; i < lines.length; i++) {
274
303
  if (/^repos:\s*(?:#.*)?$/.test(lines[i])) { startIdx = i; break }
@@ -281,15 +310,44 @@ export function parseRepoRegistry(yamlText) {
281
310
  // 条目格式: <key>: <value>(key 限 [A-Za-z0-9_.\-],与 parseLocalYamlModules 对齐)
282
311
  const entry = line.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
283
312
  if (!entry) continue
284
- const value = (entry[2] || '').trim()
285
- if (value === '' || value.startsWith('#')) continue
286
- // 去可选行内注释(`value # comment`)—— 注意路径不含 ` #`,注释前必带空格
287
- const cleaned = value.replace(/\s+#.*$/, '').trim()
288
- if (cleaned === '') continue
289
- // 去首尾可选引号(YAML 字符串引用),保留路径内反斜杠原样
290
- const path = cleaned.replace(/^['"]|['"]$/g, '')
291
- if (path === '') continue
292
- reg.set(entry[1], path)
313
+ const key = entry[1]
314
+ const indent = line.length - line.trimStart().length
315
+ const rawValue = (entry[2] || '').trim()
316
+
317
+ if (rawValue !== '' && !rawValue.startsWith('#')) {
318
+ if (rawValue.startsWith('{') && rawValue.endsWith('}')) {
319
+ // inline flow 对象:拆逗号取目标子键(值内逗号不支持——路径含逗号请用块式)
320
+ let hit = null
321
+ for (const part of rawValue.slice(1, -1).split(',')) {
322
+ const idx = part.indexOf(':')
323
+ if (idx === -1) continue
324
+ if (part.slice(0, idx).trim() === subkey) { hit = cleanVal(part.slice(idx + 1)); break }
325
+ }
326
+ if (hit) reg.set(key, hit)
327
+ } else if (subkey === 'path') {
328
+ // 字符串条目:值即 path(worktree 子键语义不适用于字符串形态)
329
+ const cleaned = cleanVal(rawValue)
330
+ if (cleaned !== '') reg.set(key, cleaned)
331
+ }
332
+ continue
333
+ }
334
+
335
+ // 块式对象条目(value 空):消费整个子键块(缩进更深的连续行),取目标子键首个命中——
336
+ // 整块消费防子键行(path:/worktree:)被外层循环误当条目再匹配('path'/'worktree' 混进
337
+ // registry 污染 Map)
338
+ let j = i + 1
339
+ for (; j < lines.length; j++) {
340
+ const l = lines[j]
341
+ if (l.trim() === '' || l.trim().startsWith('#')) continue
342
+ const subIndent = l.length - l.trimStart().length
343
+ if (subIndent <= indent) break // 离开本条目块(下一条目或段结束)
344
+ const sub = l.match(/^\s+([A-Za-z0-9_.\-]+):\s*(.*)$/)
345
+ if (sub && sub[1] === subkey && !reg.has(key)) {
346
+ const cleaned = cleanVal(sub[2])
347
+ if (cleaned !== '' && !cleaned.startsWith('#')) reg.set(key, cleaned)
348
+ }
349
+ }
350
+ i = j - 1 // 整块已消费;外层 ++ 落在块尾后第一行(下一条目或段边界)
293
351
  }
294
352
  return reg
295
353
  }
package/src/sync.js CHANGED
@@ -2333,6 +2333,9 @@ export function listConflictFiles(cwd) {
2333
2333
  // 清单为锚做全树 diff,与变更目录无关——triggerSync 对 quick 会话降级只调本函数,
2334
2334
  // 不走 sync()(后者第二道 existsSync 门会以「变更不存在」提前 return)。
2335
2335
  // 未连接平台 → {synced: 0} 静默(本地合法状态,与 syncSpecTree 内部口径一致)。
2336
+ // 2026-10-11-sync-conflict-tri-fix FR-03:changeName=null 是合法形态——CLI 手动
2337
+ // 全树同步入口(platform sync 无 --change)经此走全树 diff;冲突归由 syncSpecTree
2338
+ // 内部 attributeChangeLabel 按路径归因(单一变更名或 'full-tree')。
2336
2339
  export async function syncSpecTreeOnly(changeName, cwd, opts = {}) {
2337
2340
  const sm = new SyncManager(cwd);
2338
2341
  const platform = sm._getPlatform();
@@ -58,7 +58,21 @@ import { runVerifyProbes, readSmokeResultRecord, deriveSmokeSectionState, scanSm
58
58
  import { parseEvidenceSlots, classifyVerifiedFile } from './verify-facts-schema.js'
59
59
 
60
60
  // 测试命令最长执行时间;超时视为失败(防止 CLI 被挂起的测试卡死)
61
- const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
61
+ // 单位统一(坑 active-pitfalls SILLYSPEC_TEST_TIMEOUT_MS 单位二义,2026-10-10 实证):
62
+ // 此前本常量裸读 env 当毫秒,而 resolveTestTimeoutMs 按文档契约(config-schema
63
+ // test_timeout_sec 同效)当秒×1000——同一变量两读法,按文档传秒值(如 1800)被批执行器
64
+ // 当 1.8s 帽、deps 批瞬时 ETIMEDOUT。统一走 parseTestTimeoutEnvMs:秒契约为准;
65
+ // ≥10000 的值按毫秒兼容解释(实证期的规避口径)并告警促改秒。
66
+ function parseTestTimeoutEnvMs(raw) {
67
+ const e = Number(raw)
68
+ if (!Number.isFinite(e) || e <= 0) return null
69
+ if (e >= 10_000) {
70
+ console.warn(`⚠️ SILLYSPEC_TEST_TIMEOUT_MS=${e} 按毫秒解释(= ${(e / 1000).toFixed(0)}s)——本变量契约单位是秒(与 test_timeout_sec 同效),建议改传秒值。`)
71
+ return e
72
+ }
73
+ return e * 1000
74
+ }
75
+ const TEST_TIMEOUT_MS = parseTestTimeoutEnvMs(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60 * 1000
62
76
 
63
77
  /**
64
78
  * 测试超时配置链(R9 实证 2026-09-23):local.yaml commands.test_timeout_sec > env
@@ -69,8 +83,7 @@ const TEST_TIMEOUT_MS = Number(process.env.SILLYSPEC_TEST_TIMEOUT_MS) || 10 * 60
69
83
  export function resolveTestTimeoutMs(yamlText, env = process.env) {
70
84
  const m = String(yamlText || '').match(/^\s*test_timeout_sec:\s*([0-9]+)\s*(?:#.*)?$/m)
71
85
  if (m && Number(m[1]) > 0) return Number(m[1]) * 1000
72
- const e = Number(env.SILLYSPEC_TEST_TIMEOUT_MS)
73
- return Number.isFinite(e) && e > 0 ? e * 1000 : TEST_TIMEOUT_MS
86
+ return parseTestTimeoutEnvMs(env.SILLYSPEC_TEST_TIMEOUT_MS) || TEST_TIMEOUT_MS
74
87
  }
75
88
  const OUTPUT_TAIL_CHARS = 4000
76
89
  // 判账失败行台账上限(坑 verify-test-reconcile-tail-blindspot):对账按完整输出判、tail 只存
@@ -1936,7 +1949,29 @@ function resolveMainChangedFiles(cwd, changeName, specBase = null) {
1936
1949
  const diffBase = meta?.baselineCommit || meta?.actualBaseHash || meta?.baseHash
1937
1950
  if (diffBase) {
1938
1951
  const gitDir = found.gitDir
1939
- const files = runGitDiffNameOnly(gitDir, `${diffBase}..HEAD`)
1952
+ // 基点 merge-base 化(坑 verify-reconcile-base-stale-parallel-drift,2026-10-10 实证:
1953
+ // 执行期把 main 合入 worktree 分支后,立项 diffBase..HEAD 把主仓并行演进算成本变更
1954
+ // 产出——对账 21 条 undeclared 噪声淹没真实信号。改锚 merge-base(<主分支>, HEAD):
1955
+ // 只含本分支自身提交;未合 main 时 merge-base == 立项点行为零变化。不放大守卫:
1956
+ // 仅当 mb 是 diffBase 的后代(窗口只收窄)才采用,否则退旧区间。
1957
+ let anchor = String(diffBase)
1958
+ try {
1959
+ const headHash = String(gitQuiet(gitDir, ['rev-parse', 'HEAD'], { timeout: 15000 }) || '').trim()
1960
+ const mainRef = typeof meta?.baseBranch === 'string' && meta.baseBranch.trim() ? meta.baseBranch.trim() : 'main'
1961
+ let mb = gitQuiet(gitDir, ['merge-base', mainRef, 'HEAD'], { timeout: 15000 })
1962
+ if (!(typeof mb === 'string' && mb.trim())) mb = gitQuiet(gitDir, ['merge-base', 'master', 'HEAD'], { timeout: 15000 })
1963
+ // 三重守卫:mb == HEAD(提交落主分支本身/in-place 形态——主分支即本变更线,
1964
+ // 收窄到 HEAD 会清空 diff 面)或 mb == 立项点(零变化)或 mb 非立项点后代
1965
+ // (窗口放大)时一律保持旧锚。仅「严格收窄」形态才采用 merge-base。
1966
+ if (typeof mb === 'string' && mb.trim()) {
1967
+ const mbHash = mb.trim()
1968
+ if (mbHash !== String(diffBase) && mbHash !== headHash && headHash) {
1969
+ const isDesc = gitQuiet(gitDir, ['merge-base', '--is-ancestor', String(diffBase), mbHash], { timeout: 15000 })
1970
+ if (isDesc === '') anchor = mbHash
1971
+ }
1972
+ }
1973
+ } catch { /* 退旧区间(不放大) */ }
1974
+ const files = runGitDiffNameOnly(gitDir, `${anchor}..HEAD`)
1940
1975
  // 坑 verify-evidence-account-diff-misses-committed-changes(2026-09-13 实证):
1941
1976
  // wt-commit 流程把本变更提交落在**主仓 HEAD**,worktree 分支不前移——仅查 worktree
1942
1977
  // 侧 diffBase..HEAD 时已提交文件全部 miss(7 task diffHit=false 连环假红,verify 两连
@@ -2801,8 +2836,19 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2801
2836
  const SUITE_META_RE = /(^|\/)run-tests\.mjs$/
2802
2837
  const jsMeta = js.filter(f => SUITE_META_RE.test(norm(f)))
2803
2838
  const jsList = js.filter(f => !SUITE_META_RE.test(norm(f)))
2839
+ // 非测试形态拆批(坑 verify-dynamic-subset-node-test-ts-source,2026-10-10 平台侧实证,
2840
+ // 用户授权修复):deps 面(FR 绑定 / 增量账本 / 依赖发现)可携带非测试文件——node --test
2841
+ // 语义「文件即测试」直跑源码必败(TS 源码 import './x.js' 无 resolver 映射
2842
+ // ERR_MODULE_NOT_FOUND)、vitest/jest 点名非测试文件 No test suite found、pytest 点名
2843
+ // 非测试 .py 0 collected exit 5——三执行批统一只收测试形态(isTestFilePath 同口径),
2844
+ // 被拆文件 nontest-skip 批点名披露(不跑不拦,绑定面脏数据可见可治理)。
2845
+ const pyNonTest = py.filter(f => !isTestFilePath(f))
2846
+ const pyTest = py.filter(f => isTestFilePath(f))
2847
+ const jsNonTest = jsList.filter(f => !isTestFilePath(f))
2848
+ const jsTest = jsList.filter(f => isTestFilePath(f))
2849
+ const nonTestFiles = [...pyNonTest, ...jsNonTest]
2804
2850
  const CAP = 30
2805
- const pyCap = py.length === 0 ? 0 : jsList.length === 0 ? CAP : Math.max(5, Math.min(CAP - 5, Math.round(CAP * py.length / (py.length + jsList.length))))
2851
+ const pyCap = pyTest.length === 0 ? 0 : jsTest.length === 0 ? CAP : Math.max(5, Math.min(CAP - 5, Math.round(CAP * pyTest.length / (pyTest.length + jsTest.length))))
2806
2852
  const jsCap = CAP - pyCap
2807
2853
  // 配额制组卷(2026-10-06-fr-regress-cap-drop):优先面整跑豁免帽,普通依赖填剩余席位;
2808
2854
  // dropped 只计普通依赖弃置(优先面零弃置是构造保证),组内序保持「优先前缀+字母序」。
@@ -2812,8 +2858,8 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2812
2858
  const ordinaryRun = ordinary.slice(0, Math.max(0, quota - prioInGroup.length))
2813
2859
  return { run: [...prioInGroup, ...ordinaryRun], prioCount: prioInGroup.length, dropped: ordinary.length - ordinaryRun.length, prioDropped: 0 }
2814
2860
  }
2815
- const pyQ = quotaFill(py, pyCap)
2816
- const jsQ = quotaFill(jsList, jsCap)
2861
+ const pyQ = quotaFill(pyTest, pyCap)
2862
+ const jsQ = quotaFill(jsTest, jsCap)
2817
2863
  const pyRun = pyQ.run
2818
2864
  const jsRun = jsQ.run
2819
2865
  // .py 运行器推断双源(2026-09-26-dynamic-test-inference):① 命中模块命令串(旧路径,兼容期);
@@ -2883,8 +2929,10 @@ function buildDepsBatches({ deps, changedFiles = [], hits = [], cwd = null, prio
2883
2929
  // dropped 只计普通依赖弃置、prioDropped=防御路径计数(构造上恒 0——quotaFill 保证优先面零弃置,
2884
2930
  // >0 即组卷逻辑被破坏,消费面 loud 披露)。js 批经 jsNative/jsProject 内容分流后计数随分流面。
2885
2931
  if (pyRun.length > 0) batches.push({ name: 'deps(auto-py)', short: 'py', command: `${pyRunner} ${pyRun.map(rebase).join(' ')}`, count: pyRun.length, prioCount: pyQ.prioCount, dropped: pyQ.dropped, prioDropped: pyQ.prioDropped, files: pyRun })
2886
- if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsList.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2932
+ if (jsNative.length > 0) batches.push({ name: 'deps(auto-js)', short: 'js', command: `node --test --test-reporter=spec --test-reporter-destination=stderr --test-reporter=tap --test-reporter-destination=stdout ${jsNative.join(' ')}`, count: jsNative.length, prioCount: jsNative.filter(f => prio(f) === 0).length, dropped: jsTest.length - jsRun.length, prioDropped: jsQ.prioDropped, tap: true, files: jsNative })
2887
2933
  if (jsMeta.length > 0) batches.push({ name: 'deps(auto-js-meta-skip)', short: 'js-meta-skip', command: null, count: jsMeta.length, dropped: 0, skip: true, files: jsMeta, reason: '套件编排器(run-tests.mjs=npm test 组卷入口)非单测文件——内嵌执行=递归全量套件(嵌套 runner 污染假败);全量门由收口实测/CI 的 npm test 承担' })
2934
+
2935
+ if (nonTestFiles.length > 0) batches.push({ name: 'deps(auto-nontest-skip)', short: 'nontest-skip', command: null, count: nonTestFiles.length, dropped: 0, skip: true, files: nonTestFiles, reason: `非测试形态文件 ${nonTestFiles.length} 个不进单测执行批(node --test 语义「文件即测试」/ vitest No test suite found / pytest 0 collected exit 5——直跑必假败;坑 verify-dynamic-subset-node-test-ts-source):${nonTestFiles.slice(0, 3).join('、')}${nonTestFiles.length > 3 ? ' 等' : ''}——FR 绑定面脏数据可 sillyspec tests --unbind --tests <路径> 清行` })
2888
2936
  if (jsProjectRun.length > 0) {
2889
2937
  if (jsxRunner) {
2890
2938
  const isVitest = /vitest/.test(jsxRunner)
@@ -1758,15 +1758,22 @@ export function parseTaskAcceptance(content) {
1758
1758
  }
1759
1759
 
1760
1760
  /**
1761
- * allowed_paths 条目是否测试形态(结构归属面):test/ 前缀,或文件名含 .test. / _test. / spec
1762
- * 惯例((?:^|[._-])test(?:[._-]|$) 覆盖 foo.test.mjs / foo_test.js / test-foo.js 三形)。
1763
- * 比探针 3 的存在性面(isTestFileName 文件名形态判定,宽松存在性提示)严格——这是 3/7 口径差异的落点。
1764
- */
1765
- function isProbe7TestPath(p) {
1761
+ * allowed_paths 条目是否测试形态(结构归属面):tests?/ 或 __tests__/ 目录段,或文件名
1762
+ * .test./.spec. js 系后缀锚,或 python test_*.py / *_test.py——与 verify-postcheck 执行侧
1763
+ * isTestFilePath 同口径(坑 verify-dynamic-subset-node-test-ts-source,2026-10-10 平台侧
1764
+ * 实证:旧 /spec/i 裸子串把 spec-sync.ts 等「文件名含 spec 字样」的源码误判为测试路径,
1765
+ * 随 trace 落盘进 FR 机器绑定行,后续变更经 FR 回归面被 node --test 直跑假败)。
1766
+ * 漏检边界(宁紧勿松):foo_test.js 连字形态不再预填——落 agent 手查是 fail-visible,
1767
+ * 误绑产假败是 fail-hidden 更糟。比探针 3 的存在性面(isTestFileName 宽松提示)严格——
1768
+ * 3/7 口径差异落点。
1769
+ */
1770
+ export function isProbe7TestPath(p) {
1766
1771
  const posix = String(p).split('\\').join('/')
1767
- if (/^test\//.test(posix)) return true
1772
+ if (/(^|\/)(tests?|__tests__)\//.test(posix)) return true
1768
1773
  const name = basename(posix)
1769
- return /(?:^|[._-])test(?:[._-]|$)/i.test(name) || /spec/i.test(name)
1774
+ return /\.(test|spec)\.[cm]?(js|jsx|ts|tsx)$/.test(name)
1775
+ || /(^|\/)test_[^\/]+\.py$/.test(posix)
1776
+ || /(^|\/)[^\/]+_test\.py$/.test(posix)
1770
1777
  }
1771
1778
 
1772
1779
  /**