sillyspec 3.26.1 → 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/classify-change.js +41 -5
- package/src/config-schema.js +287 -0
- package/src/db.js +1 -1
- package/src/hooks/worktree-guard.js +3 -2
- package/src/index.js +96 -7
- package/src/init.js +13 -0
- package/src/machine-interface.js +11 -4
- package/src/run/command.js +4 -2
- 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 +15 -3
- package/src/run/quick-audit.js +12 -3
- package/src/run/shared.js +179 -6
- package/src/stage-review.js +1 -0
- package/src/stages/execute.js +185 -7
- package/src/stages/plan-postcheck.js +266 -11
- package/src/stages/plan.js +5 -0
- package/src/sync.js +125 -40
- 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/task-review.js
CHANGED
|
@@ -12,13 +12,38 @@ import { existsSync, readFileSync, mkdirSync, writeFileSync, readdirSync, statSy
|
|
|
12
12
|
import { join, resolve } from 'path'
|
|
13
13
|
import { execFileSync } from 'child_process'
|
|
14
14
|
import { pathMatches } from './change-list.js'
|
|
15
|
-
import { parseAllowedPaths } from './stages/plan-postcheck.js'
|
|
15
|
+
import { parseAllowedPaths, parseRepo, parseBaseCommit, parseHeadCommit } from './stages/plan-postcheck.js'
|
|
16
16
|
import { resolveVerifyChangedFiles } from './verify-postcheck.js'
|
|
17
17
|
import { WorktreeManager } from './worktree.js'
|
|
18
18
|
import { resolveRuntimeRoot } from './run/shared.js'
|
|
19
19
|
|
|
20
20
|
// ── review.json schema version ──
|
|
21
|
+
//
|
|
22
|
+
// design §7.4 / R-07:task-review 的 review.json schemaVersion 1→2(新增可选 repo 字段),
|
|
23
|
+
// 旧 v1(无 repo)向后兼容视 'main',不阻断既有 change archive。
|
|
24
|
+
//
|
|
25
|
+
// 关于常量与"1→2":REVIEW_SCHEMA_VERSION 常量被 stage-review.js(文档型阶段评审)共享——
|
|
26
|
+
// stage-review.test.mjs:358 + register.test.mjs:40 断言其值 === 1,且 stage-review.js 不在本次
|
|
27
|
+
// 变更的 allowed_paths 内。两个 schema(task code-review / stage doc-review)逻辑独立,但历史共享
|
|
28
|
+
// 同一常量。本期保持 REVIEW_SCHEMA_VERSION=1 作 stage-review 写侧默认值(不破坏 stage-review),
|
|
29
|
+
// 同时用 REVIEW_SCHEMA_VERSIONS_ACCEPTED=[1,2] 表达"读侧接受两版"——validateReviewSchema 兼容 v1/v2,
|
|
30
|
+
// 跨仓 task 写 review.json 可用 v2 带 repo 字段,单仓/旧 change 仍用 v1。后续可拆分 stage-review 专用
|
|
31
|
+
// 常量,本期遵循 allowed_paths 约束不扩散到 stage-review.js。
|
|
21
32
|
export const REVIEW_SCHEMA_VERSION = 1
|
|
33
|
+
export const REVIEW_SCHEMA_VERSIONS_ACCEPTED = [1, 2]
|
|
34
|
+
// repo 缺省值(review.repo / task 卡 repo 缺省时统一按 'main' 处理)。design §7.2 / §7.4。
|
|
35
|
+
export const DEFAULT_REPO_KEY = 'main'
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 规范化 review.repo / task 卡 repo 字段为有效 repoKey。
|
|
39
|
+
* 缺省 / 非字符串 / 空串 → 'main'(向后兼容旧 v1 review + 无 repo 声明的单仓 task)。
|
|
40
|
+
* @param {any} repo
|
|
41
|
+
* @returns {string}
|
|
42
|
+
*/
|
|
43
|
+
export function normalizeRepoKey(repo) {
|
|
44
|
+
if (typeof repo !== 'string' || repo.trim() === '') return DEFAULT_REPO_KEY
|
|
45
|
+
return repo.trim()
|
|
46
|
+
}
|
|
22
47
|
|
|
23
48
|
// ── 合法 verdict 枚举 ──
|
|
24
49
|
export const VALID_VERDICTS = ['pass', 'fail', 'cannot_verify']
|
|
@@ -156,6 +181,12 @@ export function summarizeTaskCompletion({ changeDir, runtimeRoot, changeName })
|
|
|
156
181
|
|
|
157
182
|
/**
|
|
158
183
|
* 校验单个 review.json 文件
|
|
184
|
+
*
|
|
185
|
+
* schema 版本(design §7.4 / R-07):接受 v1(无 repo,单仓向后兼容)+ v2(新增可选 repo 字段,
|
|
186
|
+
* 跨仓 task 标记 repo: <key>)。repo 缺省 / 非字符串 → 视 'main'(normalizeRepoKey),不阻断。
|
|
187
|
+
* repo 的真实存在性(local.yaml repos 注册)由 MultiRepoContext 约束② fail-closed 兜底,schema 层
|
|
188
|
+
* 只做"非字符串时不阻断"的宽松类型校验(review.json 是 agent 手写产物,宽松避免误杀)。
|
|
189
|
+
*
|
|
159
190
|
* @param {object} review - 解析后的 JSON 对象
|
|
160
191
|
* @returns {{ ok: boolean, errors: string[] }}
|
|
161
192
|
*/
|
|
@@ -166,14 +197,20 @@ export function validateReviewSchema(review) {
|
|
|
166
197
|
return { ok: false, errors }
|
|
167
198
|
}
|
|
168
199
|
|
|
169
|
-
if (review.schemaVersion
|
|
170
|
-
errors.push(`schemaVersion 应为 ${
|
|
200
|
+
if (!REVIEW_SCHEMA_VERSIONS_ACCEPTED.includes(review.schemaVersion)) {
|
|
201
|
+
errors.push(`schemaVersion 应为 ${REVIEW_SCHEMA_VERSIONS_ACCEPTED.join(' 或 ')},实际为 ${review.schemaVersion}`)
|
|
171
202
|
}
|
|
172
203
|
|
|
173
204
|
if (!review.task || typeof review.task !== 'string') {
|
|
174
205
|
errors.push('缺少 task 字段(应为 "task-XX" 格式)')
|
|
175
206
|
}
|
|
176
207
|
|
|
208
|
+
// repo(可选,v2 新增):类型宽松校验。非字符串 / 空串 → 视 'main',不阻断 schema。
|
|
209
|
+
// 跨仓 repo 的注册存在性由 MultiRepoContext 构造时 fail-closed 校验(约束②)。
|
|
210
|
+
if (review.repo !== undefined && typeof review.repo !== 'string') {
|
|
211
|
+
errors.push(`repo 字段(若提供)应为字符串,实际类型为 ${typeof review.repo}`)
|
|
212
|
+
}
|
|
213
|
+
|
|
177
214
|
if (!VALID_VERDICTS.includes(review.specVerdict)) {
|
|
178
215
|
errors.push(`specVerdict 无效:${review.specVerdict}(应为 ${VALID_VERDICTS.join('/')})`)
|
|
179
216
|
}
|
|
@@ -306,6 +343,11 @@ export function validateCheckedTaskReviews({ planContent, runtimeRoot, executeRu
|
|
|
306
343
|
* @param {string} opts.runtimeRoot - .sillyspec/.runtime 的绝对路径
|
|
307
344
|
* @param {string} opts.executeRunId - execute run id(如 'exec-2026-06-23-131400')
|
|
308
345
|
* @param {boolean} [opts.allowCannotVerify=true] - 是否允许 cannot_verify(默认允许,给 warning)
|
|
346
|
+
* @param {string} [opts.changeDir] - change 目录(读 task-NN.md frontmatter 判 low_risk)
|
|
347
|
+
* @param {string} [opts.gitDir] - 主仓 git 工作目录(单仓 review 真实性校验 cwd;无 ctx 时用此)
|
|
348
|
+
* @param {object} [opts.ctx] - MultiRepoContext 实例(D-013:缺省/null → 走 opts.gitDir 单仓零回归;
|
|
349
|
+
* 有 ctx 时按 review.repo(缺省 'main')从 ctx.resolve(repo).gitDir 取每条 review 的校验 cwd,
|
|
350
|
+
* 跨仓 gitDir=跨仓仓根,使 base..head diff 在正确仓跑——design §6 task-review A1 行)
|
|
309
351
|
* @returns {{ ok: boolean, errors: string[], warnings: string[], requiredEvidence: Array<{task: string, verdict: string, evidence: string[]}> }}
|
|
310
352
|
*/
|
|
311
353
|
/**
|
|
@@ -327,7 +369,7 @@ function isTaskLowRisk(changeDir, taskId) {
|
|
|
327
369
|
}
|
|
328
370
|
|
|
329
371
|
export function validateTaskReviews(opts) {
|
|
330
|
-
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null } = opts
|
|
372
|
+
const { planContent, runtimeRoot, executeRunId, allowCannotVerify = true, changeDir = null, gitDir = null, ctx = null } = opts
|
|
331
373
|
|
|
332
374
|
const taskIds = parseTaskIdsFromPlan(planContent)
|
|
333
375
|
|
|
@@ -382,8 +424,23 @@ export function validateTaskReviews(opts) {
|
|
|
382
424
|
}
|
|
383
425
|
|
|
384
426
|
// ── git 真实性交叉校验:base/head 必须是真实 commit,diff 不能为空 ──
|
|
385
|
-
|
|
386
|
-
|
|
427
|
+
// D-013 + design §6 A1:有 ctx 时按 review.repo(缺省 'main')切 gitDir——跨仓 review 的
|
|
428
|
+
// base/head 是跨仓仓 commit,必须在跨仓仓根跑 rev-parse/diff,否则判伪造误杀。无 ctx 时退回
|
|
429
|
+
// opts.gitDir(单仓零回归)。reviewGitDir 为 null 则跳过证据校验(与原逻辑一致)。
|
|
430
|
+
let reviewGitDir = gitDir
|
|
431
|
+
if (ctx) {
|
|
432
|
+
const repoKey = normalizeRepoKey(review.repo)
|
|
433
|
+
const entry = ctx.resolve(repoKey)
|
|
434
|
+
if (entry && entry.gitDir) {
|
|
435
|
+
reviewGitDir = entry.gitDir
|
|
436
|
+
} else if (repoKey !== 'main') {
|
|
437
|
+
// 跨仓 repo 在 ctx 中未注册 → 不应发生(MultiRepoContext 构造已 fail-closed 拦截未注册),
|
|
438
|
+
// 防御性降级为 warning 提示,不阻断(review.json schema 已过,仓库解析交给 ctx 上游负责)。
|
|
439
|
+
warnings.push(`${taskId}: review.repo="${repoKey}" 在 MultiRepoContext 未解析到 entry,退回主仓 gitDir 校验`)
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
if (reviewGitDir) {
|
|
443
|
+
const evidence = verifyReviewGitEvidence(review, reviewGitDir)
|
|
387
444
|
for (const w of evidence.warnings) warnings.push(`${taskId}: ${w}`)
|
|
388
445
|
if (!evidence.ok) {
|
|
389
446
|
for (const err of evidence.errors) errors.push(`${taskId}: ${err}`)
|
|
@@ -681,10 +738,10 @@ export function resolveLatestExecuteRunId({ runtimeRoot, changeName }) {
|
|
|
681
738
|
* fail-open:任何异常 → 返回统计,仅 console.warn,不阻断 execute 完成(草稿是兜底,
|
|
682
739
|
* 缺了也只是退回原状——gate 报缺 review.json,agent 手补,不比修复前差)。
|
|
683
740
|
*
|
|
684
|
-
* @param {{ changeName: string, cwd: string, platformOpts?: object }} opts
|
|
741
|
+
* @param {{ changeName: string, cwd: string, platformOpts?: object, ctx?: object|null }} opts
|
|
685
742
|
* @returns {Promise<{ generated: number, skipped: number, unattributed: string[], reason?: string, executeRunId?: string }>}
|
|
686
743
|
*/
|
|
687
|
-
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {} }) {
|
|
744
|
+
export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts = {}, ctx = null }) {
|
|
688
745
|
const specBase = platformOpts.specRoot || join(cwd, '.sillyspec')
|
|
689
746
|
const runtimeRoot = resolveRuntimeRoot(platformOpts, specBase)
|
|
690
747
|
const changeDir = join(specBase, 'changes', changeName)
|
|
@@ -709,11 +766,15 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
709
766
|
|
|
710
767
|
// base..head diff 文件集(worktree-aware;null=git 不可用,[]=无 commit diff)
|
|
711
768
|
const diffFiles = resolveVerifyChangedFiles(cwd, changeName)
|
|
712
|
-
|
|
769
|
+
// 单仓模式(无 ctx):主仓无 diff 即无任何 task 可生成 → 提前返回(原逻辑零回归)。
|
|
770
|
+
// 有 ctx:主仓无 diff 不阻断——跨仓 task 的 diff 在跨仓仓根独立取(per-task),主仓 task 自然跳过(空 changedFiles)。
|
|
771
|
+
if (!ctx && (!diffFiles || diffFiles.length === 0)) {
|
|
713
772
|
return { generated: 0, skipped: 0, unattributed: [], executeRunId, reason: 'base..head 无代码 diff(改动未 commit?)' }
|
|
714
773
|
}
|
|
715
774
|
|
|
716
775
|
// base/head + gitDir:与 gates.js reviewGitDir 同源(worktree 优先,in-place 回退 cwd)
|
|
776
|
+
// 有 ctx 时主仓 base/head 仅供主仓 task 用;ctx 主仓 entry 也可提供 base(meta.baseHash 同源)。
|
|
777
|
+
// 主仓 base/head 解析失败 + 无 ctx → 提前返回(原逻辑);有 ctx → 仅记 null,主仓 task 后续跳过(不阻断跨仓 task)。
|
|
717
778
|
let base = null
|
|
718
779
|
let head = null
|
|
719
780
|
let reviewGitDir = cwd
|
|
@@ -726,12 +787,19 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
726
787
|
}
|
|
727
788
|
} catch {}
|
|
728
789
|
if (!base) {
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
}
|
|
734
|
-
|
|
790
|
+
if (!ctx) {
|
|
791
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: '无 worktree meta.baseHash(无法定 base/head)' }
|
|
792
|
+
}
|
|
793
|
+
// 有 ctx:主仓 base 缺失(主仓无 worktree / 单纯跨仓 change)→ 主仓 task 后续跳过,跨仓 task 照常
|
|
794
|
+
} else {
|
|
795
|
+
try {
|
|
796
|
+
head = runGit(reviewGitDir, ['rev-parse', 'HEAD'])
|
|
797
|
+
} catch {
|
|
798
|
+
if (!ctx) {
|
|
799
|
+
return { generated: 0, skipped: 0, unattributed: diffFiles, executeRunId, reason: 'git rev-parse HEAD 失败(' + reviewGitDir + ')' }
|
|
800
|
+
}
|
|
801
|
+
// 有 ctx:主仓 HEAD 失败不阻断跨仓 task;head 留 null,主仓 task 后续按空 changedFiles 跳过
|
|
802
|
+
}
|
|
735
803
|
}
|
|
736
804
|
|
|
737
805
|
const taskFiles = readdirSync(tasksDir).filter(f => /^task-\d+\.md$/.test(f)).sort()
|
|
@@ -753,34 +821,76 @@ export async function generateTaskReviewDrafts({ changeName, cwd, platformOpts =
|
|
|
753
821
|
|
|
754
822
|
const content = readFileSync(join(tasksDir, tf), 'utf8')
|
|
755
823
|
const allowedPaths = parseAllowedPaths(content)
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
824
|
+
|
|
825
|
+
// ── 多仓分支(design §6 A2 / D-006 / D-010):跨仓 task 用 task 卡双锡点 + 跨仓仓 diff ──
|
|
826
|
+
const taskRepo = normalizeRepoKey(parseRepo(content))
|
|
827
|
+
const crossEntry = (ctx && taskRepo !== DEFAULT_REPO_KEY) ? ctx.resolve(taskRepo) : null
|
|
828
|
+
let taskBase = base
|
|
829
|
+
let taskHead = head
|
|
830
|
+
let taskChangedFiles
|
|
831
|
+
let draftRepo
|
|
832
|
+
|
|
833
|
+
if (crossEntry) {
|
|
834
|
+
// 跨仓 task:base/head 必须读 task 卡锡点(跨仓仓无 meta.json)。缺任一 → 跳过留给 agent。
|
|
835
|
+
const baseCommit = parseBaseCommit(content)
|
|
836
|
+
const headCommit = parseHeadCommit(content)
|
|
837
|
+
if (!baseCommit || !headCommit) {
|
|
838
|
+
skipped++
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
taskBase = baseCommit
|
|
842
|
+
taskHead = headCommit
|
|
843
|
+
draftRepo = taskRepo
|
|
844
|
+
// 跨仓 diff 在跨仓仓根跑(base..head 锡点锚定,非瞬时 HEAD——D-010 head 精度)
|
|
845
|
+
let crossDiffFiles = []
|
|
846
|
+
try {
|
|
847
|
+
const out = runGit(crossEntry.gitDir, ['diff', '--name-only', `${baseCommit}..${headCommit}`])
|
|
848
|
+
crossDiffFiles = out ? out.split('\n').filter(Boolean).map(p => p.replace(/\\/g, '/')) : []
|
|
849
|
+
} catch {
|
|
850
|
+
// git diff 失败(锡点非真实 commit 等)→ 不生成伪造草稿,留给 agent 手补
|
|
851
|
+
skipped++
|
|
852
|
+
continue
|
|
853
|
+
}
|
|
854
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
855
|
+
? crossDiffFiles.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
856
|
+
: crossDiffFiles
|
|
857
|
+
} else {
|
|
858
|
+
// 主仓 task(taskRepo='main' 或无 ctx 跨仓解析失败 → 退单仓口径)
|
|
859
|
+
// diffFiles 可能为 null(git 不可用)——有 ctx 时主仓 git 异常不阻断跨仓 task,主仓 task 自然跳过。
|
|
860
|
+
const mainDiff = Array.isArray(diffFiles) ? diffFiles : []
|
|
861
|
+
taskChangedFiles = allowedPaths.length > 0
|
|
862
|
+
? mainDiff.filter(f => allowedPaths.some(p => pathMatches(f, p)))
|
|
863
|
+
: []
|
|
864
|
+
}
|
|
759
865
|
|
|
760
866
|
// 空 changedFiles 的 task 不生成(verifyReviewGitEvidence 判空 diff 伪造,留给 agent 手补)
|
|
761
|
-
if (
|
|
867
|
+
if (taskChangedFiles.length === 0) {
|
|
762
868
|
skipped++
|
|
763
869
|
continue
|
|
764
870
|
}
|
|
765
|
-
|
|
871
|
+
// unattributed 只统计主仓 diff(跨仓 diff 路径相对跨仓仓根,与主仓 unattributed 语义无关)
|
|
872
|
+
if (!crossEntry) {
|
|
873
|
+
taskChangedFiles.forEach(f => attributed.add(f))
|
|
874
|
+
}
|
|
766
875
|
|
|
767
876
|
const draft = {
|
|
768
877
|
schemaVersion: REVIEW_SCHEMA_VERSION,
|
|
769
878
|
task: taskId,
|
|
770
|
-
base,
|
|
771
|
-
head,
|
|
772
|
-
changedFiles,
|
|
879
|
+
base: taskBase,
|
|
880
|
+
head: taskHead,
|
|
881
|
+
changedFiles: taskChangedFiles,
|
|
773
882
|
specVerdict: 'cannot_verify',
|
|
774
883
|
qualityVerdict: 'cannot_verify',
|
|
775
884
|
requiredEvidence: ['auto-generated draft: 待 agent 对照 ' + taskId + ' brief + diff 复核后升级为 pass/fail'],
|
|
776
|
-
reviewerNotes: 'auto-generated draft from git diff ' +
|
|
885
|
+
reviewerNotes: 'auto-generated draft from git diff ' + taskBase.slice(0, 8) + '..' + taskHead.slice(0, 8) + ';verdict=未评审(worktree execute 主 agent 实现模式兜底,坑2)',
|
|
777
886
|
}
|
|
887
|
+
if (draftRepo) draft.repo = draftRepo
|
|
778
888
|
mkdirSync(reviewDir, { recursive: true })
|
|
779
889
|
writeFileSync(reviewPath, JSON.stringify(draft, null, 2) + '\n')
|
|
780
890
|
generated++
|
|
781
891
|
}
|
|
782
892
|
|
|
783
|
-
const unattributed = diffFiles.filter(f => !attributed.has(f))
|
|
893
|
+
const unattributed = (Array.isArray(diffFiles) ? diffFiles : []).filter(f => !attributed.has(f))
|
|
784
894
|
return { generated, skipped, unattributed, executeRunId }
|
|
785
895
|
}
|
|
786
896
|
|
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
|
/**
|