sillyspec 3.32.4 → 3.32.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/skills/sillyspec-auto/SKILL.md +1 -1
- package/.claude/skills/sillyspec-brainstorm/SKILL.md +2 -3
- package/.claude/skills/sillyspec-explore/SKILL.md +2 -0
- package/.claude/skills/sillyspec-quick/SKILL.md +116 -0
- package/package.json +1 -1
- package/src/config-schema.js +8 -0
- package/src/cross-placement.js +246 -0
- package/src/cross-repo-reconcile.js +8 -5
- package/src/doctor-diagnostics.js +21 -1
- package/src/flow-parity.js +14 -1
- package/src/flow-review.js +11 -2
- package/src/flow.js +52 -12
- package/src/hooks/worktree-guard.js +35 -2
- package/src/index.js +22 -3
- package/src/machine-interface.js +10 -5
- package/src/run/command.js +19 -0
- package/src/run/multi-repo-context.js +11 -5
- package/src/run/shared.js +10 -4
- package/src/scope-audit.js +130 -85
- package/src/spec-sync.js +45 -16
- package/src/stages/plan-postcheck.js +67 -9
- package/src/sync.js +3 -0
- package/src/verify-postcheck.js +56 -8
- package/src/verify-probes.js +14 -7
- package/src/worktree-apply.js +62 -8
- package/src/worktree-cross.js +188 -27
- package/src/worktree-deps.js +50 -9
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}(文件树本次未同步,下次自动重试): ${
|
|
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] 拉取清单异常(文件树本次未同步,下次自动重试): ${
|
|
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] 无差异,跳过同步: ${
|
|
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}(文件树本次未同步,下次自动重试): ${
|
|
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,
|
|
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] 变更 ${
|
|
752
|
-
return { synced: 0, conflict: true, serverVersions: real, autoResolved: followers.length, conflictPath: join(specRoot, '.runtime', `spec-sync-conflict-${
|
|
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,
|
|
781
|
+
clearSpecConflictMarker(specRoot, conflictLabel);
|
|
761
782
|
noteSyncSuccess();
|
|
762
|
-
console.log(`[spec-sync] 冲突已自动消解(0 个需人工裁决;本会话改动 ${ops.length - followers.length > 0 ? '已同步' : '无差异'}): ${
|
|
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-${
|
|
790
|
+
conflictPath = join(runtimeDir, `spec-sync-conflict-${conflictLabel}.json`);
|
|
770
791
|
writeFileSync(conflictPath, JSON.stringify({
|
|
771
|
-
change:
|
|
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 ${
|
|
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} 个文件变更: ${
|
|
814
|
+
console.log(`[spec-sync] 已同步 ${ops.length} 个文件变更: ${logLabel}`);
|
|
794
815
|
// 本轮无冲突 = 此前的未决 spec 树冲突已不存在,清残留标记(否则 status 永久红标、
|
|
795
|
-
// 横幅去重单行提示不消——坑 spec-sync-conflict-banner-spam
|
|
796
|
-
|
|
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] 同步异常(文件树本次未同步,下次自动重试): ${
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
if (
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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();
|
package/src/verify-postcheck.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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(
|
|
2816
|
-
const jsQ = quotaFill(
|
|
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:
|
|
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)
|
package/src/verify-probes.js
CHANGED
|
@@ -1758,15 +1758,22 @@ export function parseTaskAcceptance(content) {
|
|
|
1758
1758
|
}
|
|
1759
1759
|
|
|
1760
1760
|
/**
|
|
1761
|
-
* allowed_paths 条目是否测试形态(结构归属面):
|
|
1762
|
-
*
|
|
1763
|
-
*
|
|
1764
|
-
|
|
1765
|
-
|
|
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 (
|
|
1772
|
+
if (/(^|\/)(tests?|__tests__)\//.test(posix)) return true
|
|
1768
1773
|
const name = basename(posix)
|
|
1769
|
-
return
|
|
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
|
/**
|
package/src/worktree-apply.js
CHANGED
|
@@ -1046,6 +1046,43 @@ export async function withMainRepoLock(projectRoot, changeName, purpose, fn, opt
|
|
|
1046
1046
|
}
|
|
1047
1047
|
}
|
|
1048
1048
|
|
|
1049
|
+
/**
|
|
1050
|
+
* 跨仓主副本直写检测(FR-06,D-003@v1——坑 cross-wt-toolchain-split 2026-10-10 用户实证:
|
|
1051
|
+
* agent 绕开 worktree 回主副本直写 master,5 个 fire commit 落主干、空 worktree 被静默清理,
|
|
1052
|
+
* 全程零提示)。
|
|
1053
|
+
*
|
|
1054
|
+
* 判定:主副本 base 锚(meta.baselineCommit||baseHash,与 worktree 交付 diff 同锚)之后有
|
|
1055
|
+
* commit 推进,且推进触及的文件与本变更该仓声明文件面(resolveApplyAllowSet 切片)以
|
|
1056
|
+
* pathMatches 容差相交 → 返回 { commits, files }(交集文件,声明面命中口径与清单校验一致)。
|
|
1057
|
+
* 纯检测不写盘:advisory,调用方(applyCrossRepoWorktrees)负责出 warning。
|
|
1058
|
+
*
|
|
1059
|
+
* fail-open(FR-07):git 不可达 / baseHash 缺失 / allowSet 空 / 任何异常 → null(调用方静默
|
|
1060
|
+
* 跳过——并行会话无关推进是合法态,误报成本高于漏报面已由 D-003 故障面接受)。
|
|
1061
|
+
*
|
|
1062
|
+
* @param {string} crossRoot 跨仓主副本仓根
|
|
1063
|
+
* @param {string} baseHash base 锚(worktree meta 的 baselineCommit||baseHash)
|
|
1064
|
+
* @param {Set<string>} allowSet 该仓声明文件面(相对跨仓仓根;空 = 声明面缺失不检测)
|
|
1065
|
+
* @returns {{ commits: number, files: string[] }|null} null = 无推进/无交集/检测不可得
|
|
1066
|
+
*/
|
|
1067
|
+
export function detectCrossMainCopyBypass(crossRoot, baseHash, allowSet) {
|
|
1068
|
+
try {
|
|
1069
|
+
if (!crossRoot || !baseHash || !(allowSet instanceof Set) || allowSet.size === 0) return null;
|
|
1070
|
+
const countRaw = gitQuiet(crossRoot, ['rev-list', '--count', `${baseHash}..HEAD`]);
|
|
1071
|
+
if (countRaw === null) return null; // git 失败(非仓/锚悬空)→ fail-open
|
|
1072
|
+
const commits = Number(String(countRaw).trim());
|
|
1073
|
+
if (!Number.isFinite(commits) || commits <= 0) return null; // 无推进 → 合法态
|
|
1074
|
+
const raw = gitQuiet(crossRoot, ['diff', '--name-only', baseHash, 'HEAD']);
|
|
1075
|
+
if (raw === null) return null;
|
|
1076
|
+
const touched = raw.split('\n').map(s => s.trim()).filter(Boolean);
|
|
1077
|
+
if (touched.length === 0) return null;
|
|
1078
|
+
const files = touched.filter(f => [...allowSet].some(ap => pathMatches(f, ap)));
|
|
1079
|
+
if (files.length === 0) return null; // 推进但与声明面无交集 → 并行会话,不告警
|
|
1080
|
+
return { commits, files };
|
|
1081
|
+
} catch {
|
|
1082
|
+
return null;
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1049
1086
|
/**
|
|
1050
1087
|
* 跨仓 worktree 真实 apply(坑 cross-repo-no-worktree-isolation,2026-08-27)。
|
|
1051
1088
|
*
|
|
@@ -1104,6 +1141,31 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
1104
1141
|
continue;
|
|
1105
1142
|
}
|
|
1106
1143
|
|
|
1144
|
+
// 声明面解析(提前到空清理分支前——直写检测需要 allowSet 覆盖「空 worktree 被静默清理」
|
|
1145
|
+
// 的 D-007 签名场景;清单校验段复用同一份,零行为差)。
|
|
1146
|
+
// specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
|
|
1147
|
+
// review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
|
|
1148
|
+
// allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
|
|
1149
|
+
// resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
|
|
1150
|
+
// 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
|
|
1151
|
+
const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
|
|
1152
|
+
const allowSet = allowMap.get(repoKey) || new Set();
|
|
1153
|
+
const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
|
|
1154
|
+
|
|
1155
|
+
// 跨仓主副本直写检测(FR-06,D-003@v1 advisory——坑 cross-wt-toolchain-split 2026-10-10
|
|
1156
|
+
// 实证:agent 绕开 worktree 回主副本直写 master,空 worktree 在此被静默清理零提示):
|
|
1157
|
+
// 主副本 base 锚之后有提交推进且推进文件与声明面相交 = 本变更签名,warning 列证。
|
|
1158
|
+
// 交集空/无推进/声明面缺失不告警(并行会话无关推进合法);检测异常 fail-open(FR-07)。
|
|
1159
|
+
try {
|
|
1160
|
+
const bypass = detectCrossMainCopyBypass(crossRoot, deliverableBase, allowSet);
|
|
1161
|
+
if (bypass) {
|
|
1162
|
+
out.warnings.push(
|
|
1163
|
+
`跨仓 ${repoKey}:疑似绕过 worktree 直写主干——base ${String(deliverableBase).slice(0, 8)} 之后主副本有 ${bypass.commits} 个 commit,以下文件与本变更声明面相交:\n ${bypass.files.join('\n ')}\n` +
|
|
1164
|
+
`(worktree 隔离被绕开:请核对该仓执行期决策留痕;若属误写应把提交摘到分支重走 apply。advisory 警告不阻断 apply。)`
|
|
1165
|
+
);
|
|
1166
|
+
}
|
|
1167
|
+
} catch { /* 直写检测 fail-open,不阻断 apply */ }
|
|
1168
|
+
|
|
1107
1169
|
if (changedFiles.length === 0) {
|
|
1108
1170
|
if (!checkOnly) {
|
|
1109
1171
|
const cr = cleanupCrossWorktrees({ cwd: crossRoot, changeName, specBase, force: true });
|
|
@@ -1117,14 +1179,6 @@ function applyCrossRepoWorktrees(changeName, projectRoot, ctx, { checkOnly = fal
|
|
|
1117
1179
|
}
|
|
1118
1180
|
|
|
1119
1181
|
// 清单校验(per-repo 切片 + review 声明相交过滤,与主仓 Gate1/3b 同语义)。
|
|
1120
|
-
// specBase 显式传(平台模式 changes/ 在 specRoot;本地模式与旧硬编码同值零回归)。
|
|
1121
|
-
// review 声明收紧(D-003@v1,2026-09-14-change-ownership-guards task-03):声明文件须与
|
|
1122
|
-
// allow 面(design 清单 ∪ 各 task target_files/allowed_paths ∪ linked-change 声明 =
|
|
1123
|
-
// resolveApplyAllowSet 现有并集)相交(pathMatches 容差)才计入;不相交的外来声明剔除出
|
|
1124
|
-
// 放行面、进 violations 报告行(「review 声明了越权文件」嫌疑标注),admission 增量归零。
|
|
1125
|
-
const allowMap = resolveApplyAllowSet(projectRoot, changeName, { specBase });
|
|
1126
|
-
const allowSet = allowMap.get(repoKey) || new Set();
|
|
1127
|
-
const reviewDeclared = collectReviewDeclaredFiles(projectRoot, changeName, { runtimeRoot: join(specBase, '.runtime') }).get(repoKey);
|
|
1128
1182
|
const reviewForeign = [];
|
|
1129
1183
|
if (reviewDeclared && allowSet.size > 0) {
|
|
1130
1184
|
const face = [...allowSet];
|