sillyspec 3.26.2 → 3.26.3
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-execute/SKILL.md +38 -0
- package/.claude/skills/sillyspec-plan/SKILL.md +48 -0
- package/package.json +1 -1
- package/src/index.js +55 -3
- package/src/machine-interface.js +11 -4
- package/src/run/complete.js +27 -4
- package/src/run/gates.js +26 -14
- package/src/run/multi-repo-context.js +206 -0
- package/src/run/prompt.js +6 -0
- package/src/run/shared.js +170 -2
- package/src/stage-review.js +1 -0
- package/src/stages/execute.js +185 -7
- package/src/stages/plan-postcheck.js +266 -11
- package/src/sync.js +24 -1
- package/src/task-review.js +135 -25
- package/src/verify-postcheck.js +242 -11
- package/src/worktree-apply.js +137 -9
- package/.sillyspec-platform-cleaned +0 -1
- package/.sillyspec-platform.json +0 -7
package/src/verify-postcheck.js
CHANGED
|
@@ -436,11 +436,62 @@ function gitChangedFiles(cwd) {
|
|
|
436
436
|
* → 命中无关模块(如 ppm 变更误测 daemon/frontend)。与 task-review.js 同源。
|
|
437
437
|
* 2. 无 worktree meta / diff 异常 → 主仓 `git diff --name-only HEAD`(brownfield 原行为)
|
|
438
438
|
*
|
|
439
|
+
* 跨仓支持(task-06 / D-004 / design §6 A6):ctx 参数可选,缺省走单仓原逻辑(零回归,
|
|
440
|
+
* GOAL-2)。ctx 非空且含跨仓 entry 时,主仓 diff 走原逻辑,再 per-repo 在各跨仓仓
|
|
441
|
+
* gitDir 跑 `git diff --name-only HEAD` 合并入结果(design §6 行 136 「per-repo 取 diff
|
|
442
|
+
* 合并」字面契约)。跨仓仓 diff 路径相对各自仓根。
|
|
443
|
+
*
|
|
444
|
+
* 注:runVerifyTestCheck 的 module 子集策略只消费主仓 diff(跨仓仓不参与 module 子集,
|
|
445
|
+
* 跨仓仓无 module 映射,design §6 + §5.4),故本函数的跨仓合并 diff 在 module 子集
|
|
446
|
+
* 路径无副作用——本函数仅供「per-repo diff 合并」语义契约 + 未来 consumer 复用。
|
|
447
|
+
*
|
|
439
448
|
* @param {string} cwd - 项目根目录(主仓)
|
|
440
449
|
* @param {string|null} changeName
|
|
450
|
+
* @param {object|null} [ctx] - MultiRepoContext 实例(可选,缺省/null 走单仓原逻辑)
|
|
441
451
|
* @returns {string[]|null} 变更文件列表;git 不可用返回 null(调用方按 hitCount=-1 处理)
|
|
442
452
|
*/
|
|
443
|
-
export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
453
|
+
export function resolveVerifyChangedFiles(cwd, changeName, ctx = null) {
|
|
454
|
+
// 主仓 diff(原逻辑不动,单仓零回归)
|
|
455
|
+
const mainFiles = resolveMainChangedFiles(cwd, changeName)
|
|
456
|
+
|
|
457
|
+
// 无 ctx / ctx 无跨仓 entry → 主仓 diff 即结果(零回归)
|
|
458
|
+
if (!ctx || typeof ctx.repos !== 'object' || ctx.repos === null) return mainFiles
|
|
459
|
+
const crossEntries = []
|
|
460
|
+
for (const entry of ctx.repos.values()) {
|
|
461
|
+
if (entry && entry.isMain === false) crossEntries.push(entry)
|
|
462
|
+
}
|
|
463
|
+
if (crossEntries.length === 0) return mainFiles
|
|
464
|
+
|
|
465
|
+
// 主仓 git 不可用(null)→ 不强制兜底,保留 null 语义让调用方按 hitCount=-1 处理。
|
|
466
|
+
// 跨仓仓 diff 各自取,任一可用即合并;全不可用且主仓 null → 返 null。
|
|
467
|
+
// 跨仓仓 diff 锚点:HEAD~1..HEAD(跨仓 task 子代理 commit 到主干,verify 时最近一笔 commit
|
|
468
|
+
// 即本次 task 改动;多 task 同仓时仅反映最近一笔,精确范围由 task 卡 base/head 锡点锚定,
|
|
469
|
+
// 那是 task-04 的 scope,本函数不消费锡点——跨仓合并 diff 在 verify-postcheck 内仅供
|
|
470
|
+
// 「per-repo diff 合并」语义契约 + 未来 consumer,module 子集只用主仓 diff)。
|
|
471
|
+
const merged = mainFiles ? mainFiles.slice() : []
|
|
472
|
+
let anyAvailable = mainFiles !== null
|
|
473
|
+
for (const entry of crossEntries) {
|
|
474
|
+
// 跨仓仓 gitDir = 跨仓仓根(MultiRepoContext._buildCrossRepoEntry 已 fail-closed 保证可达)
|
|
475
|
+
const files = runGitDiffNameOnly(entry.gitDir, 'HEAD~1..HEAD')
|
|
476
|
+
if (files !== null) {
|
|
477
|
+
anyAvailable = true
|
|
478
|
+
for (const f of files) {
|
|
479
|
+
if (!merged.includes(f)) merged.push(f)
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
return anyAvailable ? merged : null
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
/**
|
|
487
|
+
* 主仓变更文件解析(原 resolveVerifyChangedFiles 逻辑,单仓零回归基线)。
|
|
488
|
+
* 抽出供 resolveVerifyChangedFiles 复用,跨仓合并时主仓部分走此函数(不动)。
|
|
489
|
+
*
|
|
490
|
+
* @param {string} cwd - 主仓根
|
|
491
|
+
* @param {string|null} changeName
|
|
492
|
+
* @returns {string[]|null}
|
|
493
|
+
*/
|
|
494
|
+
function resolveMainChangedFiles(cwd, changeName) {
|
|
444
495
|
if (changeName) {
|
|
445
496
|
const metaPath = join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json')
|
|
446
497
|
if (existsSync(metaPath)) {
|
|
@@ -464,10 +515,18 @@ export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
|
464
515
|
/**
|
|
465
516
|
* 执行 verify 实测:读取 local.yaml 配置,按 test_strategy 决定全量或模块子集。
|
|
466
517
|
*
|
|
518
|
+
* 跨仓支持(task-06 / D-004 / design §5.4 + §6 A6 + §9):opts.ctx 可选,缺省走单仓
|
|
519
|
+
* 原逻辑(零回归,GOAL-2)。ctx 非空且含跨仓 entry 时,主仓跑原 module 子集 / full npm
|
|
520
|
+
* test(行为不变),再 per-repo cwd 在各跨仓仓根跑 full npm test(跨仓仓不参与 module
|
|
521
|
+
* 子集策略——跨仓仓无主仓的 module 映射,design §6 + §5.4);跨仓仓无 package.json 则
|
|
522
|
+
* 跳过 + warn 不阻断 verify(design §9 兼容策略「跨仓仓无 package.json 跳过 warn」)。
|
|
523
|
+
* 任一仓 fail → 整体 fail(fail-fast 语义,跨仓仓的真实失败不能被主仓 PASS 掩盖)。
|
|
524
|
+
*
|
|
467
525
|
* @param {object} opts
|
|
468
|
-
* @param {string} opts.cwd -
|
|
526
|
+
* @param {string} opts.cwd - 项目根目录(主仓,测试执行目录)
|
|
469
527
|
* @param {string} opts.specBase - .sillyspec(或平台 specRoot)目录
|
|
470
528
|
* @param {string|null} [opts.changeName]
|
|
529
|
+
* @param {object|null} [opts.ctx] - MultiRepoContext 实例(可选,缺省/null 走单仓原逻辑)
|
|
471
530
|
* @returns {{
|
|
472
531
|
* status: 'passed'|'failed'|'skipped',
|
|
473
532
|
* command: string|null,
|
|
@@ -478,7 +537,7 @@ export function resolveVerifyChangedFiles(cwd, changeName) {
|
|
|
478
537
|
* resultPath: string|null,
|
|
479
538
|
* }}
|
|
480
539
|
*/
|
|
481
|
-
export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
540
|
+
export function runVerifyTestCheck({ cwd, specBase, changeName = null, ctx = null }) {
|
|
482
541
|
const localYamlPath = join(specBase, 'local.yaml')
|
|
483
542
|
const yamlText = existsSync(localYamlPath) ? readFileSync(localYamlPath, 'utf8') : null
|
|
484
543
|
|
|
@@ -487,6 +546,8 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
|
487
546
|
|
|
488
547
|
// —— 模块子集路径(test_strategy: module):算 modulesPresent / hitCount / hits ——
|
|
489
548
|
// resolveVerifyChangedFiles 返回 null 表示 git 不可用 → hitCount=-1(与 0 命中区分)。
|
|
549
|
+
// 注:module 子集策略只用主仓 diff(跨仓仓不参与 module 子集,design §6 + §5.4),
|
|
550
|
+
// 故此处不传 ctx(避免跨仓路径误命中主仓 module 映射)。
|
|
490
551
|
let modulesPresent = false
|
|
491
552
|
let hitCount = 0
|
|
492
553
|
let hits = []
|
|
@@ -505,13 +566,13 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
|
505
566
|
}
|
|
506
567
|
|
|
507
568
|
const action = decideVerifyTestAction({ strategy, modulesPresent, hitCount })
|
|
569
|
+
let mainResult
|
|
508
570
|
if (action === 'module-subset') {
|
|
509
|
-
|
|
510
|
-
}
|
|
511
|
-
if (action === 'module-zero-hit-skip') {
|
|
571
|
+
mainResult = runModuleSubset({ cwd, specBase, changeName, hits, knownFailures })
|
|
572
|
+
} else if (action === 'module-zero-hit-skip') {
|
|
512
573
|
// module 模式 0 命中:不静默回退注定超时/含预存失败的全量(坑 verify-worktree-... 修复方向 3)。
|
|
513
574
|
// 据 verify-result.md 自报告判定;想跑全量请显式设 test_strategy: full。
|
|
514
|
-
|
|
575
|
+
mainResult = {
|
|
515
576
|
status: 'skipped',
|
|
516
577
|
command: null,
|
|
517
578
|
exitCode: null,
|
|
@@ -522,12 +583,182 @@ export function runVerifyTestCheck({ cwd, specBase, changeName = null }) {
|
|
|
522
583
|
mode: 'module-zero-hit',
|
|
523
584
|
fallbackReason: null,
|
|
524
585
|
}
|
|
586
|
+
} else {
|
|
587
|
+
// —— 全量路径(full / module 无块 / module git 不可用)——
|
|
588
|
+
// fallbackReason 非 null 表示本次全量是"非显式"的(缺省/配置不全/未命中),需明示。
|
|
589
|
+
const fallbackReason = computeFullFallbackReason({ strategy, modulesPresent, hitCount })
|
|
590
|
+
mainResult = runFullCommand({ yamlText, localYamlPath, cwd, specBase, changeName, fallbackReason, knownFailures })
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
// —— 跨仓仓 per-repo cwd 跑 full npm test(task-06 / D-004 / design §5.4 + §6 A6)——
|
|
594
|
+
// ctx 为空或无跨仓 entry → 直接返主仓结果(单仓零回归,GOAL-2)。
|
|
595
|
+
// 跨仓仓不参与 module 子集策略,只跑 full npm test(design §6 + §5.4);
|
|
596
|
+
// 跨仓仓无 package.json → 跳过 + warn 不阻断(design §9 兼容策略)。
|
|
597
|
+
// 任一跨仓仓 fail → 整体 fail(合并语义)。
|
|
598
|
+
return mergeCrossRepoResults(mainResult, ctx)
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* per-repo cwd 跑跨仓仓 full npm test,合并进主仓结果。
|
|
603
|
+
*
|
|
604
|
+
* design §6 A6 + §5.4 + §9 兼容策略:
|
|
605
|
+
* - 跨仓仓有 package.json → 在该仓 projectRoot cwd 跑 full npm test(跨仓仓 own local.yaml
|
|
606
|
+
* 若有 commands.test 用之,否则 fallback `npm test`)
|
|
607
|
+
* - 跨仓仓无 package.json → 跳过 + console.warn(不阻断 verify)
|
|
608
|
+
* - 跨仓仓只跑 full npm test,不参与 module 子集策略(module 映射主仓强相关)
|
|
609
|
+
* - 任一仓 fail → 整体 fail;主仓 skipped + 跨仓仓 passed → 整体 passed(跨仓仓有测试即有效)
|
|
610
|
+
*
|
|
611
|
+
* 单仓 ctx(无跨仓 entry)→ 直接返主仓结果,零行为变化(GOAL-2)。
|
|
612
|
+
*
|
|
613
|
+
* @param {object} mainResult - 主仓 runVerifyTestCheck 结果
|
|
614
|
+
* @param {object|null} ctx - MultiRepoContext 实例
|
|
615
|
+
* @returns {object} 合并后结果(shape 同 mainResult)
|
|
616
|
+
*/
|
|
617
|
+
function mergeCrossRepoResults(mainResult, ctx) {
|
|
618
|
+
if (!ctx || typeof ctx.repos !== 'object' || ctx.repos === null) return mainResult
|
|
619
|
+
const crossEntries = []
|
|
620
|
+
for (const entry of ctx.repos.values()) {
|
|
621
|
+
if (entry && entry.isMain === false) crossEntries.push(entry)
|
|
622
|
+
}
|
|
623
|
+
if (crossEntries.length === 0) return mainResult
|
|
624
|
+
|
|
625
|
+
const crossResults = []
|
|
626
|
+
for (const entry of crossEntries) {
|
|
627
|
+
const crossResult = runCrossRepoFullTest(entry)
|
|
628
|
+
crossResults.push({ repoKey: entry.repoKey, projectRoot: entry.projectRoot, result: crossResult })
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
// 合并:任一 fail → fail;否则取主仓 status(passed/skipped 与跨仓 passed 合并)
|
|
632
|
+
const failedRepos = crossResults.filter(r => r.result.status === 'failed')
|
|
633
|
+
if (failedRepos.length > 0) {
|
|
634
|
+
return mergeResultStatus({
|
|
635
|
+
status: 'failed',
|
|
636
|
+
mainResult,
|
|
637
|
+
crossResults,
|
|
638
|
+
reason: `跨仓仓测试失败:${failedRepos.map(r => r.repoKey).join(', ')}`,
|
|
639
|
+
})
|
|
640
|
+
}
|
|
641
|
+
// 跨仓仓全 passed 或 skipped(无 package.json)→ 主仓 status 不变(合并跨仓信息到 outputTail)
|
|
642
|
+
return mergeResultInfo(mainResult, crossResults)
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/**
|
|
646
|
+
* 在单个跨仓仓根跑 full npm test(跨仓仓不参与 module 子集,只跑 full)。
|
|
647
|
+
* 跨仓仓 own local.yaml 若存在且配 commands.test → 用之;否则 fallback `npm test`。
|
|
648
|
+
* 跨仓仓无 package.json → 跳过 + warn。
|
|
649
|
+
*
|
|
650
|
+
* @param {object} entry - RepoEntry(isMain=false)
|
|
651
|
+
* @returns {object} 结果 shape 对齐 runFullCommand 返回
|
|
652
|
+
*/
|
|
653
|
+
function runCrossRepoFullTest(entry) {
|
|
654
|
+
const projectRoot = entry.projectRoot
|
|
655
|
+
// 跨仓仓无 package.json → 跳过 + warn(design §9 兼容策略,不阻断 verify)
|
|
656
|
+
if (!existsSync(join(projectRoot, 'package.json'))) {
|
|
657
|
+
console.warn(`⚠️ 跨仓 repo "${entry.repoKey}"(${projectRoot})无 package.json,跳过该仓 npm test(design §9 兼容策略,不阻断 verify)。`)
|
|
658
|
+
return {
|
|
659
|
+
status: 'skipped',
|
|
660
|
+
command: null,
|
|
661
|
+
exitCode: null,
|
|
662
|
+
durationMs: null,
|
|
663
|
+
outputTail: null,
|
|
664
|
+
reason: `跨仓 repo "${entry.repoKey}" 无 package.json,跳过 npm test`,
|
|
665
|
+
resultPath: null,
|
|
666
|
+
mode: 'cross-repo-skip',
|
|
667
|
+
repoKey: entry.repoKey,
|
|
668
|
+
}
|
|
525
669
|
}
|
|
526
670
|
|
|
527
|
-
//
|
|
528
|
-
//
|
|
529
|
-
const
|
|
530
|
-
|
|
671
|
+
// 跨仓仓 own local.yaml(在跨仓仓 .sillyspec/ 下,若存在)配 commands.test → 用之;否则 `npm test`
|
|
672
|
+
// 注:跨仓仓按 NG-1 不建 .sillyspec/,但容错读取(用户可手动放 local.yaml 配跨仓仓特定测试命令)
|
|
673
|
+
const crossLocalYaml = join(projectRoot, '.sillyspec', 'local.yaml')
|
|
674
|
+
let command = 'npm test'
|
|
675
|
+
let crossKnownFailures = []
|
|
676
|
+
if (existsSync(crossLocalYaml)) {
|
|
677
|
+
try {
|
|
678
|
+
const crossYaml = readFileSync(crossLocalYaml, 'utf8')
|
|
679
|
+
const extracted = extractTestCommand(crossYaml)
|
|
680
|
+
if (extracted) command = extracted
|
|
681
|
+
crossKnownFailures = extractKnownFailures(crossYaml)
|
|
682
|
+
} catch { /* 读取失败 fallback npm test */ }
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
const startedAt = Date.now()
|
|
686
|
+
let exitCode = 0
|
|
687
|
+
let output = ''
|
|
688
|
+
let reason = null
|
|
689
|
+
try {
|
|
690
|
+
output = execSync(command, {
|
|
691
|
+
cwd: projectRoot,
|
|
692
|
+
encoding: 'utf8',
|
|
693
|
+
timeout: TEST_TIMEOUT_MS,
|
|
694
|
+
maxBuffer: 32 * 1024 * 1024,
|
|
695
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
696
|
+
})
|
|
697
|
+
} catch (e) {
|
|
698
|
+
exitCode = typeof e.status === 'number' ? e.status : 1
|
|
699
|
+
output = [e.stdout, e.stderr].filter(Boolean).join('\n') || e.message
|
|
700
|
+
reason = e.signal === 'SIGTERM' && Date.now() - startedAt >= TEST_TIMEOUT_MS
|
|
701
|
+
? `跨仓 repo "${entry.repoKey}" 测试超时(>${TEST_TIMEOUT_MS / 1000}s)`
|
|
702
|
+
: `跨仓 repo "${entry.repoKey}" 测试退出码 ${exitCode}`
|
|
703
|
+
}
|
|
704
|
+
const durationMs = Date.now() - startedAt
|
|
705
|
+
const outputTail = output.length > OUTPUT_TAIL_CHARS ? '…' + output.slice(-OUTPUT_TAIL_CHARS) : output
|
|
706
|
+
const judged = judgeWithKnownFailures(exitCode, output, reason, crossKnownFailures)
|
|
707
|
+
|
|
708
|
+
return {
|
|
709
|
+
status: judged.status,
|
|
710
|
+
command,
|
|
711
|
+
exitCode,
|
|
712
|
+
durationMs,
|
|
713
|
+
outputTail,
|
|
714
|
+
reason: judged.reason,
|
|
715
|
+
resultPath: null,
|
|
716
|
+
mode: 'cross-repo-full',
|
|
717
|
+
repoKey: entry.repoKey,
|
|
718
|
+
exemptedCount: judged.exemptedCount,
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
|
|
722
|
+
/**
|
|
723
|
+
* 合并失败状态:整体 failed,保留主仓 + 各跨仓仓明细到 outputTail / reason。
|
|
724
|
+
*/
|
|
725
|
+
function mergeResultStatus({ status, mainResult, crossResults, reason }) {
|
|
726
|
+
const parts = []
|
|
727
|
+
parts.push(`── main (${mainResult.status}) ──\n${mainResult.outputTail || mainResult.reason || ''}`)
|
|
728
|
+
for (const cr of crossResults) {
|
|
729
|
+
parts.push(`── cross-repo ${cr.repoKey} (${cr.result.status}) ──\n${cr.result.outputTail || cr.result.reason || ''}`)
|
|
730
|
+
}
|
|
731
|
+
const mergedTail = parts.join('\n')
|
|
732
|
+
return {
|
|
733
|
+
status,
|
|
734
|
+
command: mainResult.command ? `${mainResult.command} + cross-repo[${crossResults.map(r => r.repoKey).join(',')}]` : `cross-repo[${crossResults.map(r => r.repoKey).join(',')}]`,
|
|
735
|
+
exitCode: 1,
|
|
736
|
+
durationMs: (mainResult.durationMs || 0) + crossResults.reduce((n, r) => n + (r.result.durationMs || 0), 0),
|
|
737
|
+
outputTail: mergedTail.length > OUTPUT_TAIL_CHARS ? '…' + mergedTail.slice(-OUTPUT_TAIL_CHARS) : mergedTail,
|
|
738
|
+
reason,
|
|
739
|
+
resultPath: mainResult.resultPath,
|
|
740
|
+
mode: 'cross-repo-merged',
|
|
741
|
+
fallbackReason: mainResult.fallbackReason || null,
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
/**
|
|
746
|
+
* 合并信息(跨仓仓全 passed/skipped):主仓 status 保留,跨仓仓 PASSED 信息附入 outputTail。
|
|
747
|
+
*/
|
|
748
|
+
function mergeResultInfo(mainResult, crossResults) {
|
|
749
|
+
const crossPassed = crossResults.filter(r => r.result.status === 'passed')
|
|
750
|
+
const crossSkipped = crossResults.filter(r => r.result.status === 'skipped')
|
|
751
|
+
if (crossPassed.length === 0 && crossSkipped.length === 0) return mainResult
|
|
752
|
+
const crossSummary = []
|
|
753
|
+
for (const cr of crossPassed) crossSummary.push(`cross-repo ${cr.repoKey}: PASS`)
|
|
754
|
+
for (const cr of crossSkipped) crossSummary.push(`cross-repo ${cr.repoKey}: SKIP(${cr.result.reason || 'no package.json'})`)
|
|
755
|
+
const crossLine = `\n── 跨仓仓 npm test ──\n${crossSummary.join('\n')}`
|
|
756
|
+
const mergedTail = (mainResult.outputTail || '') + crossLine
|
|
757
|
+
return {
|
|
758
|
+
...mainResult,
|
|
759
|
+
outputTail: mergedTail.length > OUTPUT_TAIL_CHARS ? '…' + mergedTail.slice(-OUTPUT_TAIL_CHARS) : mergedTail,
|
|
760
|
+
mode: mainResult.mode ? `${mainResult.mode}+cross-repo` : 'cross-repo-merged',
|
|
761
|
+
}
|
|
531
762
|
}
|
|
532
763
|
|
|
533
764
|
/**
|
package/src/worktree-apply.js
CHANGED
|
@@ -18,8 +18,9 @@ import { join, resolve } from 'path';
|
|
|
18
18
|
import { tmpdir } from 'os';
|
|
19
19
|
import { WorktreeManager } from './worktree.js';
|
|
20
20
|
import { parseFileChangeList, parseFileChangeListDetailed, pathMatches } from './change-list.js';
|
|
21
|
-
import { parseAllowedPaths } from './stages/plan-postcheck.js';
|
|
21
|
+
import { parseAllowedPaths, parseRepo } from './stages/plan-postcheck.js';
|
|
22
22
|
import { git, gitQuiet } from './git-helper.js';
|
|
23
|
+
import { resolveLatestExecuteRunId, readReview } from './task-review.js';
|
|
23
24
|
|
|
24
25
|
const CHANGES_REL = '.sillyspec/changes';
|
|
25
26
|
|
|
@@ -191,36 +192,145 @@ function getBlobHashMap(cwd, treeish, files) {
|
|
|
191
192
|
* 两 gate 口径不一致)。union 后两源并集为准;plan 已过 validateDesignFileCoverage 单向校验(design ⊆
|
|
192
193
|
* plan),union 不会放开 design/plan 之外的任意文件(仍拦完全越界文件)。
|
|
193
194
|
*
|
|
195
|
+
* **跨仓切片(task-05 / D-009 / design §6 A4)**:返回 `Map<repoKey, Set<path>>`,按 task 卡片
|
|
196
|
+
* `repo:` 字段把 allowed_paths 归属到对应 repo;design §6 清单归属 'main'(清单文件相对主仓根)。
|
|
197
|
+
* 单仓 change(task 卡无 repo:)→ 所有 allowed_paths 归 'main' → `{main: Set}` 退化零回归
|
|
198
|
+
* (GOAL-2)。allowed_paths 基准=各 repo 自身根(跨仓 task 卡片 allowed_paths 相对跨仓仓根,design §7.2)。
|
|
199
|
+
*
|
|
200
|
+
* 主流程(applyWorktree A5 主仓 apply)只消费 main 仓的 Set(主仓 worktree diff 只含主仓文件,
|
|
201
|
+
* 跨仓文件不在主仓 worktree 故永不进 diff);跨仓 task 走 no-op(D-009),其 allowed_paths 仅
|
|
202
|
+
* 作 Map 切片返回,供上游(assess / 跨仓 review 校验)按 repo 取用。
|
|
203
|
+
*
|
|
194
204
|
* @param {string} projectRoot - 主仓库根
|
|
195
205
|
* @param {string} changeName - 变更名
|
|
196
|
-
* @returns {Set<string
|
|
206
|
+
* @returns {Map<string, Set<string>>} repoKey → 并集清单 Set(无 design 清单且无 task 卡片时为空 Map)
|
|
197
207
|
*/
|
|
198
208
|
export function resolveApplyAllowSet(projectRoot, changeName) {
|
|
199
|
-
const
|
|
209
|
+
const repoMap = new Map();
|
|
210
|
+
const getOrCreate = (key) => {
|
|
211
|
+
if (!repoMap.has(key)) repoMap.set(key, new Set());
|
|
212
|
+
return repoMap.get(key);
|
|
213
|
+
};
|
|
214
|
+
// design §6 清单归属 main(清单路径相对主仓根;跨仓 task 的 allowed_paths 由 task 卡片 repo 切片)
|
|
215
|
+
const mainSet = getOrCreate('main');
|
|
216
|
+
parseFileChangeList(
|
|
217
|
+
join(projectRoot, CHANGES_REL, changeName, 'design.md'),
|
|
218
|
+
{ keepSillyspecDocs: true }
|
|
219
|
+
).forEach(p => mainSet.add(p));
|
|
200
220
|
const tasksDir = join(projectRoot, CHANGES_REL, changeName, 'tasks');
|
|
201
221
|
if (existsSync(tasksDir)) {
|
|
202
222
|
for (const tf of readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f))) {
|
|
203
|
-
|
|
223
|
+
const content = readFileSync(join(tasksDir, tf), 'utf8');
|
|
224
|
+
// task 卡 repo: 字段切片(缺省='main',design §7.2);跨仓 task 的 allowed_paths 相对跨仓仓根。
|
|
225
|
+
const repoKey = parseRepo(content) || 'main';
|
|
226
|
+
const repoSet = getOrCreate(repoKey);
|
|
227
|
+
for (const p of parseAllowedPaths(content)) repoSet.add(p);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
return repoMap;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* 跨仓 task apply = no-op 校验(task-05 / D-009 / design §6 A3 G1)。
|
|
235
|
+
*
|
|
236
|
+
* 跨仓 task 的 commit 已由子代理直接落跨仓仓主干(NG-3),apply 阶段无 patch 可打(跨仓仓无
|
|
237
|
+
* worktree/meta/分支——A5 patch 路径不可复用)。本函数对每个跨仓 repo 的 task review.head
|
|
238
|
+
* 校验是否为该跨仓仓真实 commit(约束①+② 保险,R-05:子代理漏 commit / head 伪造 → 这里拦)。
|
|
239
|
+
*
|
|
240
|
+
* 不调 wm.cleanup(跨仓仓无主仓 worktree 可清——wm.cleanup 只作用主仓 worktree,跨仓仓主干工作区
|
|
241
|
+
* 永不进主仓 worktree 生命周期,无需也无法 cleanup)。这是 D-009 no-op 的关键:跨仓 commit 已落主干,
|
|
242
|
+
* apply 只校验不复用 patch、不 cleanup。
|
|
243
|
+
*
|
|
244
|
+
* review.json 物理在主仓 execute-runs(D-003),含 repo + head 字段(design §7.4)。本函数读主仓
|
|
245
|
+
* execute-runs 取最新 runId 的 reviews,按 review.repo 切片到对应跨仓 entry 校验 head。
|
|
246
|
+
*
|
|
247
|
+
* 无 review.json(execute 未走完 / 单 task --done 中途)→ 记 warning 不阻断(apply 可在 review 落盘前
|
|
248
|
+
* 被调用,如 worktree apply --check-only 预检;真阻断由 Task Review Gate 负责,这里不重复)。
|
|
249
|
+
*
|
|
250
|
+
* @param {object} ctx - MultiRepoContext(design §7.1)
|
|
251
|
+
* @param {string} projectRoot - 主仓根(读 .sillyspec/.runtime/execute-runs)
|
|
252
|
+
* @param {string} changeName
|
|
253
|
+
* @returns {{ errors: string[], warnings: string[], validated: Array<{repo:string, task:string, head:string}> }}
|
|
254
|
+
*/
|
|
255
|
+
function validateCrossRepoNoOp(ctx, projectRoot, changeName) {
|
|
256
|
+
const errors = [];
|
|
257
|
+
const warnings = [];
|
|
258
|
+
const validated = [];
|
|
259
|
+
if (!ctx || typeof ctx.hasCrossRepo !== 'function' || !ctx.hasCrossRepo()) {
|
|
260
|
+
return { errors, warnings, validated }; // 单仓退化:无跨仓,no-op 无事可做(零回归)
|
|
261
|
+
}
|
|
262
|
+
// review.json 物理在主仓 .runtime/execute-runs(D-003)。runtimeRoot = <projectRoot>/.sillyspec/.runtime
|
|
263
|
+
const runtimeRoot = join(projectRoot, '.sillyspec', '.runtime');
|
|
264
|
+
const runId = resolveLatestExecuteRunId({ runtimeRoot, changeName });
|
|
265
|
+
if (!runId) {
|
|
266
|
+
// 无 execute run → 无 review 可校验。不阻断(Task Review Gate 负责拦截 review 缺失),
|
|
267
|
+
// 仅 warn 提示跨仓 head 校验被跳过(约束①保险在此场景未触发,但功能不崩)。
|
|
268
|
+
warnings.push(`跨仓 task no-op 校验跳过:未找到 execute runId(${runtimeRoot}),无法读取跨仓 review.head。跨仓 commit 校验交由 Task Review Gate 负责。`);
|
|
269
|
+
return { errors, warnings, validated };
|
|
270
|
+
}
|
|
271
|
+
const tasksDir = join(runtimeRoot, 'execute-runs', runId, 'tasks');
|
|
272
|
+
if (!existsSync(tasksDir)) {
|
|
273
|
+
warnings.push(`跨仓 task no-op 校验跳过:execute run ${runId} 无 tasks/ 目录。`);
|
|
274
|
+
return { errors, warnings, validated };
|
|
275
|
+
}
|
|
276
|
+
// 遍历 ctx.repos,只对跨仓 entry(isMain=false)校验。每 entry 读所有 review.json 取 repo 匹配的 head。
|
|
277
|
+
for (const [repoKey, entry] of ctx.repos) {
|
|
278
|
+
if (entry.isMain) continue; // 主仓走 A5,不在此 no-op
|
|
279
|
+
// 扫 tasks/ 下 review.json,取 repo === repoKey 的 head 校验
|
|
280
|
+
let taskEntries = [];
|
|
281
|
+
try { taskEntries = readdirSync(tasksDir).filter(f => /^task-\d+$/.test(f)); } catch { taskEntries = []; }
|
|
282
|
+
for (const taskId of taskEntries) {
|
|
283
|
+
const reviewPath = join(tasksDir, taskId, 'review.json');
|
|
284
|
+
if (!existsSync(reviewPath)) continue;
|
|
285
|
+
const r = readReview(reviewPath);
|
|
286
|
+
// readReview 走 validateReviewSchema(task-03 扩展 repo 字段后兼容);review 不完整则跳过(不在此拦)
|
|
287
|
+
if (!r.ok || !r.review) continue;
|
|
288
|
+
const reviewRepo = r.review.repo || 'main'; // schemaVersion=1 无 repo 视 main(design §9 兼容)
|
|
289
|
+
if (reviewRepo !== repoKey) continue;
|
|
290
|
+
const head = r.review.head;
|
|
291
|
+
if (!head) continue; // schema 校验已挡缺 head,防御性跳过
|
|
292
|
+
// 校验 head 是该跨仓仓真实 commit:git cat-file -e HEAD~0 / rev-parse 校验存在
|
|
293
|
+
// 用 cat-file -e <head> 校验 commit 存在(rev-parse 对缩写/不存在都非零退出)
|
|
294
|
+
const exists = gitQuiet(entry.gitDir, ['cat-file', '-e', head]);
|
|
295
|
+
if (exists === null) {
|
|
296
|
+
errors.push(
|
|
297
|
+
`跨仓 task no-op 校验失败:task ${taskId}(repo: ${repoKey})的 review.head ${head} ` +
|
|
298
|
+
`不是跨仓仓 ${entry.gitDir} 的真实 commit(git cat-file -e 失败)。` +
|
|
299
|
+
`跨仓 task 的 commit 必须由子代理直接落跨仓仓主干(D-009),apply 为 no-op 不复用 patch,` +
|
|
300
|
+
`故 head 真实性是跨仓改动落地的唯一保险(约束①+②,R-05)。请检查子代理是否漏 commit 或 head 锡点漂移。`
|
|
301
|
+
);
|
|
302
|
+
} else {
|
|
303
|
+
validated.push({ repo: repoKey, task: taskId, head });
|
|
304
|
+
}
|
|
204
305
|
}
|
|
205
306
|
}
|
|
206
|
-
return
|
|
307
|
+
return { errors, warnings, validated };
|
|
207
308
|
}
|
|
208
309
|
|
|
209
310
|
/**
|
|
210
311
|
* apply worktree 变更到主工作区
|
|
211
312
|
*
|
|
313
|
+
* **跨仓 task no-op(task-05 / D-009 / design §6 A3/A5)**:当传 ctx 且 ctx.hasCrossRepo() 时,
|
|
314
|
+
* 跨仓 task 的 commit 已由子代理直接落跨仓仓主干(NG-3 跨仓不经主仓 worktree),apply 阶段对跨仓
|
|
315
|
+
* task = no-op(无 patch 可打,跨仓仓无 worktree/meta/分支——A5 patch 路径不可复用)。只校验跨仓
|
|
316
|
+
* task review.head 是跨仓仓真实 commit(约束①+② 保险,R-05)+ 不调 wm.cleanup(跨仓仓无主仓 worktree
|
|
317
|
+
* 可清,wm.cleanup 只作用于主仓 worktree)。主仓 task 走原 A5 完整 apply 不动(GOAL-2 单仓零回归)。
|
|
318
|
+
*
|
|
212
319
|
* @param {string} changeName - 变更名
|
|
213
|
-
* @param {{ cwd?: string, checkOnly?: boolean }} opts
|
|
320
|
+
* @param {{ cwd?: string, checkOnly?: boolean, merge?: boolean, ctx?: object }} opts
|
|
321
|
+
* - ctx:可选 MultiRepoContext(design §7.1)。缺省=单仓退化(仅主仓 apply,零行为变化,GOAL-2)。
|
|
322
|
+
* 提供 ctx 且含跨仓 entry 时,触发跨仓 no-op 校验(校验 review.head 真实 + 不 cleanup 跨仓)。
|
|
214
323
|
* @returns {{
|
|
215
324
|
* ok: boolean,
|
|
216
325
|
* changedFiles: string[],
|
|
217
326
|
* extraFiles: string[],
|
|
218
327
|
* hashMismatchFiles: string[],
|
|
219
328
|
* patchPath: string|null,
|
|
220
|
-
* errors: string[]
|
|
329
|
+
* errors: string[],
|
|
330
|
+
* crossRepoValidated?: Array<{ repo: string, head: string }>
|
|
221
331
|
* }}
|
|
222
332
|
*/
|
|
223
|
-
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false } = {}) {
|
|
333
|
+
export function applyWorktree(changeName, { cwd, checkOnly = false, merge = false, ctx = null } = {}) {
|
|
224
334
|
const projectRoot = cwd || process.cwd();
|
|
225
335
|
const wm = new WorktreeManager({ cwd: projectRoot });
|
|
226
336
|
const meta = wm.getMeta(changeName);
|
|
@@ -234,8 +344,22 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
234
344
|
patchPath: null,
|
|
235
345
|
errors: [],
|
|
236
346
|
merged: false,
|
|
347
|
+
crossRepoValidated: [],
|
|
237
348
|
};
|
|
238
349
|
|
|
350
|
+
// --- 0. 跨仓 task no-op 校验(task-05 / D-009) ---
|
|
351
|
+
// 跨仓 task 的 commit 已落跨仓仓主干,apply = no-op(无 patch)。仅校验 review.head 是跨仓仓真实
|
|
352
|
+
// commit + 不 cleanup(wm.cleanup 只作用主仓 worktree)。校验失败 → 推 errors 阻断 apply(R-05)。
|
|
353
|
+
// 单仓 ctx 缺省 / 无跨仓 entry → validateCrossRepoNoOp 直接返回空(零回归)。
|
|
354
|
+
if (ctx) {
|
|
355
|
+
const cross = validateCrossRepoNoOp(ctx, projectRoot, changeName);
|
|
356
|
+
for (const e of cross.errors) result.errors.push(e);
|
|
357
|
+
if (cross.warnings.length > 0) result.warnings = (result.warnings || []).concat(cross.warnings);
|
|
358
|
+
result.crossRepoValidated = cross.validated;
|
|
359
|
+
// 跨仓 head 校验失败 = 跨仓改动未真落地,apply 不可继续(即使主仓 patch 能跑,跨仓 task 实际未完成)
|
|
360
|
+
if (cross.errors.length > 0) return result;
|
|
361
|
+
}
|
|
362
|
+
|
|
239
363
|
// --- 1. 校验 worktree 存在 + meta.json 有效 ---
|
|
240
364
|
if (!meta) {
|
|
241
365
|
result.errors.push(`worktree not found: ${changeName}。meta.json 不存在或已损坏。`);
|
|
@@ -297,7 +421,11 @@ export function applyWorktree(changeName, { cwd, checkOnly = false, merge = fals
|
|
|
297
421
|
}
|
|
298
422
|
|
|
299
423
|
// --- 3. 解析 apply 文件清单(design §6 清单 ∪ plan TaskCard allowed_paths,execute 复盘 c) ---
|
|
300
|
-
|
|
424
|
+
// resolveApplyAllowSet 返回 Map<repo, Set>(task-05 跨仓切片);主仓 apply 只消费 main 仓 Set
|
|
425
|
+
// (主仓 worktree diff 只含主仓文件,跨仓文件在跨仓仓不进主仓 worktree diff)。跨仓 allowed_paths
|
|
426
|
+
// 仅作 Map 切片返回供上游用,主流程不消费(跨仓 apply=no-op,D-009)。
|
|
427
|
+
const allowMap = resolveApplyAllowSet(projectRoot, changeName);
|
|
428
|
+
const allowSet = allowMap.get('main') || new Set();
|
|
301
429
|
const hasAllowList = allowSet.size > 0;
|
|
302
430
|
|
|
303
431
|
// --- 3.5 主干已提交推进检测:hashMismatch 前移(Grill P0 修复,design §step 顺序修正)---
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
2026-08-10T17:08:53.237Z
|