sillyspec 3.28.10 → 3.28.12

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.
Files changed (52) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +223 -222
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -10
  4. package/.claude/skills/sillyspec-verify/SKILL.md +91 -87
  5. package/.husky/pre-commit +4 -0
  6. package/.husky/pre-push +3 -0
  7. package/AGENTS.md +1 -1
  8. package/package.json +1 -1
  9. package/src/change-risk-profile.js +76 -6
  10. package/src/commit-guard.js +119 -0
  11. package/src/config-schema.js +23 -0
  12. package/src/design-facts.js +46 -9
  13. package/src/diagnostic-codes.js +95 -0
  14. package/src/docs-check.js +14 -10
  15. package/src/docs-debt.js +7 -2
  16. package/src/docs-gate.js +42 -15
  17. package/src/doctor-diagnostics.js +141 -1
  18. package/src/endpoint-extractor.js +50 -2
  19. package/src/foreign-declared.js +25 -16
  20. package/src/hooks/worktree-guard.js +75 -2
  21. package/src/index.js +129 -1
  22. package/src/machine-interface.js +63 -0
  23. package/src/probe7-anchor-check.js +20 -2
  24. package/src/progress/stage-machine.js +34 -5
  25. package/src/progress.js +1 -1
  26. package/src/quicklog.js +231 -1
  27. package/src/run/complete-handlers.js +172 -1
  28. package/src/run/complete.js +73 -3
  29. package/src/run/gate-snapshot.js +262 -0
  30. package/src/run/gates.js +41 -7
  31. package/src/run/prompt.js +79 -1
  32. package/src/run/shared.js +10 -0
  33. package/src/run/verify-quality-scan.js +151 -5
  34. package/src/stage-contract-spec.js +14 -0
  35. package/src/stage-contract.js +738 -8
  36. package/src/stage-review-checklist.js +16 -2
  37. package/src/stage-review.js +137 -2
  38. package/src/stages/archive.js +9 -5
  39. package/src/stages/brainstorm.js +7 -1
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/plan-postcheck.js +42 -5
  42. package/src/stages/plan.js +8 -1
  43. package/src/stages/quick.js +5 -5
  44. package/src/stages/verify.js +8 -3
  45. package/src/sync.js +69 -0
  46. package/src/task-review.js +9 -3
  47. package/src/verify-facts-schema.js +95 -10
  48. package/src/verify-postcheck.js +346 -28
  49. package/src/verify-probes.js +1221 -46
  50. package/src/worktree-apply.js +175 -3
  51. package/src/write-audit.js +55 -0
  52. package/.claude/full.json +0 -1
@@ -17,6 +17,9 @@ import { evaluateRules } from './stage-contract-engine.js'
17
17
  import { getRule } from './stage-contract-spec.js'
18
18
  import { backfillFrontmatter } from './scan-postcheck.js'
19
19
  import { parseAllowedPaths } from './stages/plan-postcheck.js'
20
+ // design.md 文件清单解析(2026-09-17-pass-cap-semantics task-02 事实③):change-list 是纯 fs
21
+ // 叶子模块(design-facts / scope-audit / verify-probes 等同款直连惯例),静态引入无环。
22
+ import { parseFileChangeListDetailed } from './change-list.js'
20
23
 
21
24
  /**
22
25
  * 校验结果
@@ -403,8 +406,17 @@ function validatePlanOutputs(cwd, changeName, context = {}) {
403
406
  const sm = fm && fm[1].match(/^scale:[ \t]*['"]?(\w+)/m)
404
407
  if (sm) scale = sm[1]
405
408
  }
409
+ // plan.md frontmatter plan_level 传入引擎 ctx(ql-20260917-003):plan.global-constraints warning
410
+ // 的 condition 用。读不到(无 frontmatter/旧格式)→ undefined → eq:'full' 不成立 → 规则跳过
411
+ // (存量零误报 fail-safe,同 scale 读取模式)。
412
+ let planLevel = null
413
+ if (existsSync(planFile)) {
414
+ const pfm = readFileSync(planFile, 'utf8').match(/^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*\r?\n/)
415
+ const plm = pfm && pfm[1].match(/^plan_level:[ \t]*['"]?(\w+)/m)
416
+ if (plm) planLevel = plm[1]
417
+ }
406
418
  // plan.md 存在性由引擎消费 manifest。entryPoint/id-trace/decisions 为 custom kind(下方保留)。
407
- const engineResult = evaluateRules('plan', { changeDir, scale })
419
+ const engineResult = evaluateRules('plan', { changeDir, scale, planLevel })
408
420
  const errors = [...engineResult.errors]
409
421
  const warnings = [...engineResult.warnings]
410
422
 
@@ -634,12 +646,23 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
634
646
  `若属关键词误伤(实际未触碰 daemon/session/启动入口/跨进程),可在 design.md frontmatter 加 risk_level: <真实等级>(如 unit-sufficient)显式覆盖后重跑。`
635
647
  )
636
648
  }
637
- // 显式 risk_level 声明(design frontmatter,非关键词误判)下,PASS WITH NOTES 视为对残留项的
638
- // 诚实声明,不强求全量集成证据——豁免本就来自 design 的明确判断。自动关键词判级维持严格:
639
- // PASS WITH NOTES 仍要求证据齐全,防 agent 用 PASS WITH NOTES 绕证据门控。
640
- const requiresEvidence = conclusion === 'PASS' || (conclusion === 'PASS WITH NOTES' && !changeRiskProfile.explicit)
641
- if (conclusion === 'PASS WITH NOTES' && changeRiskProfile.explicit) {
642
- warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:design frontmatter 显式声明 risk_level=${changeRiskProfile.level},残留项须为真实集成证据缺口,并在 verify-result.md 如实说明`)
649
+ // ── 显式 risk_level 豁免洞分层(D-002@v1,2026-09-17-pass-cap-semantics task-02)──
650
+ // ① explicit + 降级 unit-sufficient/contract-required:不进本 critical 块(上方 level 门),
651
+ // PASS WITH NOTES 维持免证据——关键词误伤逃生保留;
652
+ // ② explicit 且仍 integration/deployment-critical + PASS WITH NOTES:必须携带结构化 handover
653
+ // (facts.handover 有效行;blocking 级计入封顶口径同 validatePassEligibility 条件②)或齐全
654
+ // 集成证据,二选一——无 handover 即挂证据门(checkIntegrationEvidence 不过 → error);
655
+ // ③ 非显式(关键词判级)判定式一字不动(防 PASS WITH NOTES 绕证据门控)。
656
+ const notesExplicitCritical = conclusion === 'PASS WITH NOTES' && changeRiskProfile.explicit
657
+ const handoverRows = notesExplicitCritical ? countFactsHandoverItems(changeDir) : 0
658
+ const requiresEvidence = conclusion === 'PASS'
659
+ || (conclusion === 'PASS WITH NOTES' && (!changeRiskProfile.explicit || handoverRows === 0))
660
+ if (notesExplicitCritical) {
661
+ if (handoverRows > 0) {
662
+ warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:design frontmatter 显式声明 risk_level=${changeRiskProfile.level},缺口由结构化移交项承载(facts.handover ${handoverRows} 行,「## 移交项(结构化)」)——blocking 级行在结论=PASS 时按封顶口径拦截(validatePassEligibility 条件②),请如实分行勿漏报。`)
663
+ } else {
664
+ warnings.push(`[${changeRiskProfile.level}] 结论 PASS WITH NOTES:显式声明 risk_level=${changeRiskProfile.level} 且「## 移交项(结构化)」零有效行——按齐全集成证据口径校验(二选一:补结构化移交项承载缺口,或提供齐全集成证据;两者皆缺将 error)。`)
665
+ }
643
666
  }
644
667
  if (requiresEvidence) {
645
668
  // CLI 回执注入(坑 verify-literal-evidence-mismatch,2026-08-22 实证:证据第一轮就齐
@@ -677,6 +700,11 @@ function validateVerifyOutputs(cwd, changeName, context = {}) {
677
700
  extraEvidenceText: receiptText,
678
701
  ...(runtimeEvidence ? { runtimeEvidence } : {}),
679
702
  ...(context.verifyStartAt ? { verifyStartAt: context.verifyStartAt } : {}),
703
+ // 回执来源声明透传(X-10 / D-006,2026-09-17-pass-cap-semantics task-02):调用侧只透传
704
+ // context 携带的声明源(quality-scan 记录的命令 / verify_precedents 声明同思想),不在
705
+ // 本侧做任何分类——分类打标单点在 change-risk-profile(auditRuntimeReceipt 内按回执
706
+ // command 来源 classifyReceiptSourceTag);未透传时按各回执 command 就地分类。
707
+ ...(context.receiptSourceTag ? { sourceTag: context.receiptSourceTag } : {}),
680
708
  cwd,
681
709
  specBase: context.specRoot || join(cwd, '.sillyspec'),
682
710
  })
@@ -820,6 +848,12 @@ export function extractAcceptanceMatrixSlots(verifyMd) {
820
848
  * - 段缺失:严格档(isIrStrictVerifyChange / IR_STRICT_SINCE 同源常量,与结论槽
821
849
  * 「删槽回退已关闭」同口径)ERROR;非严格(存量变更)warning 提示补段不阻断
822
850
  * - 段在场:unfilled / missingEvidence > 0 → ERROR 列出违规行(task+acceptance 截断 40 字)
851
+ * - 段在场:partial/uncovered 行 >0 且 facts.handover 零有效行(任意 severity)→ ERROR
852
+ * 「部分实现必须有移交去向」(D-003/FR-04,task-03;矩阵 MD 槽解析即防篡改锚点 X-05——
853
+ * 不读 facts.matrixPartialRows producer 快照,锚定当前文档实态;factsExpected=false 存量
854
+ * 零行为变化,factsExpected=true 而 facts 缺失 → fail-closed 按零有效行处理)
855
+ * - 同分支放行路径 advisory(R-05 第一版):handover 有效行在场时逐行核对行标识(task 锚 /
856
+ * acceptance 文本)↔ 条目 item/condition 文本命中,未命中 → console.warn 攒实证,不阻断
823
857
  */
824
858
  function validateAcceptanceMatrix(cwd, changeName, context = {}) {
825
859
  const { specRoot } = context
@@ -867,9 +901,700 @@ function validateAcceptanceMatrix(cwd, changeName, context = {}) {
867
901
  `修复:在证据列补测试锚点(如 \`test/foo.test.mjs\` 或 \`src/x.js:42\`)或 non-testable 理由。`
868
902
  )
869
903
  }
904
+
905
+ // ── partial/uncovered × facts.handover 联动(D-003/FR-04,2026-09-17-pass-cap-semantics task-03)──
906
+ // 部分实现必须有移交去向:矩阵含 partial/uncovered 行且 facts.handover 零有效行(任意
907
+ // severity)→ error;有行 → 本分支放行,blocking 级是否封顶归 validatePassEligibility 条件②
908
+ // (④管「有去向」、②管「去向是否 blocking」)。handover 数据只读 facts.handover(task-01
909
+ // producer 四列产出;不在此重复解析 MD 移交项表——防篡改锚点由上方矩阵 MD 槽解析承担,X-05)。
910
+ // factsExpected 口径复用 task-02 判定式:false(存量未跑管线)→ 分支空转零行为变化;
911
+ // true 而 facts 缺失 → 按零有效行 fail-closed,文案附「重跑 verify-probes」出路。
912
+ // 放行路径上做逐行关联 advisory(R-05 第一版:验收项 ID ↔ handover 条目文本命中,只攒实证
913
+ // 不做硬门):行标识(task / acceptance 文本)未命中任何条目 → console.warn 提示,不阻断不进 errors。
914
+ const partialRows = matrix.rows.filter(r => r.verdict === 'partial' || r.verdict === 'uncovered')
915
+ if (partialRows.length > 0 && resolveFactsExpected(changeDir)) {
916
+ const facts = readFactsForEligibility(changeDir)
917
+ const factsMissing = facts === null
918
+ // 有效行口径同 countFactsHandoverItems(task-02):只计对象行;此处需逐条取文本故就地展开
919
+ const handoverItems = facts && facts.handover && Array.isArray(facts.handover.items)
920
+ ? facts.handover.items.filter(it => it && typeof it === 'object') : []
921
+ if (handoverItems.length === 0) {
922
+ const list = partialRows.map(rowLabel).join(';')
923
+ errors.push(
924
+ `探针 7 验收×测试覆盖矩阵含 ${partialRows.length} 行 partial/uncovered 且「## 移交项(结构化)」零有效行(facts.handover)——部分实现必须有移交去向:${list}。` +
925
+ `修复:在 verify-result.md 补「## 移交项(结构化)」有效行(任意 severity 均可,blocking 级另受 PASS 封顶约束),或修正矩阵判定(实际已覆盖的行改 covered),或降级结论为 PASS WITH NOTES 承载。` +
926
+ (factsMissing
927
+ ? `另:verify-facts.json 缺失/不可读而 factsExpected=true,fail-closed 按零有效行处理——重跑 verify-probes(\`sillyspec verify-probes --change ${changeName} --init\` 幂等刷新底稿)。`
928
+ : '')
929
+ )
930
+ } else {
931
+ // 逐行关联 advisory(R-05 第一版,D-003):行标识(task 锚 / acceptance 文本)命中任一
932
+ // handover 条目的 item/condition 文本即视为有去向;未命中 → console.warn 攒实证(文本
933
+ // 命中是弱关联,误报/漏报均可能——先 advisory 攒数据,硬门等实证后再议),不阻断。
934
+ const itemTexts = handoverItems.map(it => `${it.item || ''}|${it.condition || ''}`)
935
+ for (const r of partialRows) {
936
+ const keys = [r.task, r.acceptance].map(k => String(k || '').trim()).filter(Boolean)
937
+ const hit = keys.some(k => itemTexts.some(t => t.includes(k)))
938
+ if (!hit) {
939
+ console.warn(`ℹ️ [advisory] 验收项 ${rowLabel(r)} 的移交去向未在 handover 条目中命中——建议条目文本含该标识(advisory,攒实证,D-003/R-05;不阻断)`)
940
+ }
941
+ }
942
+ }
943
+ }
870
944
  return { ok: errors.length === 0, errors, warnings }
871
945
  }
872
946
 
947
+ // ============ 接口验证覆盖矩阵门禁(2026-09-17-api-coverage-smoke task-05 / FR-04~FR-06) ============
948
+ //
949
+ // 与探针 7(验收×测试承接面)并排互补的独立对账门(design §4,D-009 非目标 / R-07 口径注记
950
+ // 互指):探针 7 管「每条 acceptance 由哪些测试承接」,本矩阵管「design 接口段每个端点由哪些
951
+ // 验证用例/冒烟步骤覆盖」——fail-closed 记账,机械封住「接口层零派生」的 P1 缺陷面:
952
+ // - covered 记账:分子=判定 covered 的端点行;有效分母=N−non-testable 行数(N=解析端点数
953
+ // 或声明数,D-005);分子<有效分母或解析面有未覆盖端点 → error 逐条列缺覆盖端点
954
+ // - 锚点五形态(design §4 Grill #10):design接口表#<METHOD /path> 解析级(须命中
955
+ // facts.apiFace 解析产出集,防空指);权限矩阵[...]/契约表@.../DDL@.../载荷@... 形态级
956
+ // 存在即认;covered/partial 行缺锚点 → error(missingEvidence 口径同 probe7)
957
+ // - 移交联动(probe7 条件④同款形态):partial/uncovered 端点行>0 且 facts.handover 零
958
+ // 有效行 → error;blocking 是否封顶归 validatePassEligibility 条件②(④管有去向/②管去向级)
959
+ // - 探索行(uncovered+[探索] 标记)与消费端子行(两空格缩进 ↳ 前缀)不进分母分子
960
+ // - 声明降级(D-005):解析零行按声明数对账;并存以解析为准并注记 warning;解析零行零
961
+ // 声明×判级 critical → error(接口面不可静默为零);判级 critical×声明 0 端点 → warning
962
+ // - advisory(D-006/D-007 第一版,不阻断不进 errors):消费端归类在场而矩阵零子行 →
963
+ // warning 列端点;写端点(POST/PUT/DELETE/PATCH)未在权限矩阵段命中且无「无权限约束」
964
+ // 豁免 → warning
965
+ // 消费路径铁律(plan 全局硬约束 3):本模块零 import verify-probes——apiFace/consumerHints
966
+ // 数据经 facts 落盘面(readFactsForEligibility 同源读取,parseDesignApiTable 产出于
967
+ // verify-probes 侧 backfill 落盘)进入;矩阵面经 verify-result.md MD 槽解析(X-05 防篡改锚点,
968
+ // 锚定当前文档实态不读 producer 快照)。
969
+
970
+ /** 矩阵段标题定位(## 级;骨架渲染带「[层:…]」后缀——前缀匹配容忍,renderApiCoverageMatrixLines 字面同源) */
971
+ const API_COVERAGE_HEADING_RE = /^## 接口验证覆盖矩阵/m
972
+ /** 端点单元格 method+path 提取(预填行 `GET /orders/{id}` 形态;宽匹配容忍注记后缀) */
973
+ const API_ENDPOINT_CELL_RE = /(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\/[^\s|]+)/
974
+ /** 声明占位行(D-005 零解析降级侧,骨架「本变更接口面:N 端点(agent 声明)」字面同源) */
975
+ const API_DECLARED_ROW_RE = /本变更接口面[::]\s*(\d+)\s*端点/
976
+ /** design接口表# 锚点提取(解析级校验用——防空指,Grill #10:非物理行号,METHOD /path 形态) */
977
+ const API_ANCHOR_DESIGN_API_RE = /design接口表#\s*(GET|POST|PUT|DELETE|PATCH|HEAD|OPTIONS)\s+(\/[^\s|,。;;))]+)/g
978
+ /** 探索行标记(骨架文法注释字面同源:证据列含 [探索]) */
979
+ const API_EXPLORATION_MARK_RE = /\[探索\]/
980
+
981
+ /**
982
+ * 行级提取 verify-result.md「## 接口验证覆盖矩阵」段(task-05 壳层 MD 槽解析,纯字符串零 IO)。
983
+ * 段定位:## 标题(前缀匹配容忍后缀)到下个同级(##)或更高级(#)标题。行分类:
984
+ * - 消费端子行:≥2 空白缩进 + ↳ 前缀(骨架文法字面同源)——只计数不进矩阵行账
985
+ * - 五列表行(复用 splitMatrixRowCells 切列):表头/分隔行跳过;端点列含声明行的 →
986
+ * declaredRow 单列(不进端点行账);其余 → 端点行(method/path 自端点列提取)
987
+ * - 声明占位行与探索行判定见上注释;CRLF/LF 容忍(\r 剥离)。
988
+ * @param {string} verifyMd verify-result.md 全文
989
+ * @returns {{
990
+ * present: boolean,
991
+ * rows: Array<{endpoint: string, method: string, path: string, verdict: string,
992
+ * caseId: string, result: string, evidence: string, unfilled?: boolean, exploration?: boolean}>,
993
+ * subRowCount: number,
994
+ * declaredRow: {declared: number, verdict: string, evidence: string}|null,
995
+ * }}
996
+ */
997
+ export function extractApiCoverageMatrixSlots(verifyMd) {
998
+ const out = { present: false, rows: [], subRowCount: 0, declaredRow: null }
999
+ const md = String(verifyMd || '')
1000
+ const hm = md.match(API_COVERAGE_HEADING_RE)
1001
+ if (!hm) return out
1002
+ out.present = true
1003
+ const rest = md.slice(hm.index + hm[0].length)
1004
+ const endMatch = rest.match(/^#{1,2}(?:[ \t]|$)/m)
1005
+ const sectionText = endMatch ? rest.slice(0, endMatch.index) : rest
1006
+
1007
+ for (const rawLine of sectionText.split(/\r?\n/)) {
1008
+ // 消费端子行(≥2 空白缩进 ↳ 前缀):不计矩阵行账,只计数(advisory 消费面判据)
1009
+ if (/^\s{2,}↳/.test(rawLine)) { out.subRowCount++; continue }
1010
+ const cells = splitMatrixRowCells(rawLine)
1011
+ if (!cells || cells.length !== 5) continue // 注记/防御/非五列表行不计槽
1012
+ const c = cells.map(x => x.trim())
1013
+ if (c.every(x => /^:?-{3,}:?$/.test(x))) continue // 分隔行
1014
+ if (c[0] === '端点' && c[1] === '判定') continue // 表头行
1015
+ const dm = c[0].match(API_DECLARED_ROW_RE)
1016
+ if (dm) {
1017
+ // 声明占位行(D-005 零解析降级侧):单列不进端点行账,declared 供 N 兜底
1018
+ out.declaredRow = { declared: parseInt(dm[1], 10), verdict: c[1], evidence: c[4] }
1019
+ continue
1020
+ }
1021
+ const em = c[0].match(API_ENDPOINT_CELL_RE)
1022
+ const row = {
1023
+ endpoint: c[0], method: em ? em[1].toUpperCase() : '', path: em ? em[2] : '',
1024
+ verdict: c[1], caseId: c[2], result: c[3], evidence: c[4],
1025
+ }
1026
+ if (!MATRIX_VERDICT_WHITELIST.has(row.verdict)) row.unfilled = true
1027
+ row.exploration = row.verdict === 'uncovered' && API_EXPLORATION_MARK_RE.test(`${row.result} ${row.evidence}`)
1028
+ out.rows.push(row)
1029
+ }
1030
+ return out
1031
+ }
1032
+
1033
+ /**
1034
+ * 证据锚点五形态命中(design §4 Grill #10):design接口表# 须提取出 ≥1 个 METHOD /path
1035
+ * 锚点才计命中(形态在而端点不可提取按缺锚计,fail-closed);其余四形态存在即认。
1036
+ * design接口表# 锚点是否命中解析产出集由调用侧逐锚核对(解析级校验),此处只管形态在场。
1037
+ */
1038
+ function apiEvidenceHasAnchorForm(evidence) {
1039
+ const e = String(evidence || '')
1040
+ API_ANCHOR_DESIGN_API_RE.lastIndex = 0
1041
+ if (API_ANCHOR_DESIGN_API_RE.test(e)) return true
1042
+ if (/权限矩阵\[[^\]]+\]/.test(e)) return true
1043
+ if (/契约表@\S/.test(e)) return true
1044
+ if (/DDL@\S/.test(e)) return true
1045
+ if (/载荷@\S/.test(e)) return true
1046
+ return false
1047
+ }
1048
+
1049
+ /**
1050
+ * 接口验证覆盖矩阵记账判定纯函数(X-09 双层形态的判定层:零 MD 解析、零 IO——矩阵面经
1051
+ * 壳层 extractApiCoverageMatrixSlots 解析传入,apiFace/consumerHints/handover 经 facts
1052
+ * 落盘面传入)。task-07 断言消费契约。
1053
+ * @param {{
1054
+ * matrix?: object|null, // extractApiCoverageMatrixSlots 产物
1055
+ * apiFace?: {endpoints?: Array<{method,path,rowIdx}>, declared?: number|null, writeEndpoints?: Array}|null,
1056
+ * consumerHints?: Record<string, string[]>|null,
1057
+ * facts?: object|null, // verify-facts.json(handover 联动用,缺 null)
1058
+ * factsExpected?: boolean, // false(存量未跑管线)→ 兼容 ok 不误伤
1059
+ * strict?: boolean, // isIrStrictVerifyChange(段缺失分层用)
1060
+ * riskLevel?: string|null, // detectChangeRisk().level
1061
+ * changeName?: string,
1062
+ * permSectionText?: string, // design.md 权限段(段头含 权限/角色 的段合并文本,壳内提取)
1063
+ * designText?: string, // design.md 全文(写端点豁免行级检测用)
1064
+ * }} [args]
1065
+ * @returns {{ ok: boolean, errors: string[], warnings: string[] }}
1066
+ */
1067
+ export function judgeApiCoverageMatrix(args) {
1068
+ const errors = []
1069
+ const warnings = []
1070
+ const a = args || {}
1071
+ // factsExpected=false(存量未跑管线)→ 沿用存量兼容口径 ok 不误伤(evaluatePassEligibility 同款边界)
1072
+ if (!a.factsExpected) return { ok: true, errors, warnings }
1073
+
1074
+ const changeName = a.changeName || '<变更名>'
1075
+ const INIT_HINT = `\`sillyspec verify-probes --change ${changeName} --init\``
1076
+ const matrix = a.matrix && typeof a.matrix === 'object' ? a.matrix : { present: false, rows: [], subRowCount: 0, declaredRow: null }
1077
+ const rows = Array.isArray(matrix.rows) ? matrix.rows : []
1078
+ const apiFace = a.apiFace && typeof a.apiFace === 'object' ? a.apiFace : null
1079
+ const endpoints = apiFace && Array.isArray(apiFace.endpoints)
1080
+ ? apiFace.endpoints.filter(e => e && e.method && e.path) : []
1081
+ const declared = apiFace && typeof apiFace.declared === 'number' ? apiFace.declared : null
1082
+ const writeEndpoints = apiFace && Array.isArray(apiFace.writeEndpoints)
1083
+ ? apiFace.writeEndpoints.filter(e => e && e.method && e.path) : []
1084
+ const consumerHints = a.consumerHints && typeof a.consumerHints === 'object' ? a.consumerHints : {}
1085
+ const criticalLevel = ['integration-critical', 'deployment-critical'].includes(a.riskLevel)
1086
+
1087
+ // ── 段缺失分层(对齐 :896-903 双层形态 + 判级 critical fail-closed 防删段绕过)。
1088
+ // apiFace 在场性 = 特性时序锚(X-08 无条件产出面,task-04 起 backfill 落盘):缺席说明
1089
+ // facts 最后一次回填早于本特性(特性间隙存量/旧管线 facts)→ warning 引导 --init 刷新
1090
+ // 即可(刷新后 apiFace 落盘、矩阵骨架补段,删段面转入上门硬口径);在场(新管线跑过,
1091
+ // 骨架必曾渲染矩阵段)而段缺失 = 删段 → 严格档/critical error。删 facts.json 的对抗面
1092
+ // 另由 checkProbeConsistency error 级 MD 锚点兜底(resolveFactsExpected 同口径注记)。──
1093
+ const newPipeline = apiFace != null
1094
+ if (!matrix.present) {
1095
+ if (newPipeline && a.strict) {
1096
+ errors.push(
1097
+ `接口验证覆盖矩阵段缺失(严格档,created_at ≥ ${IR_STRICT_SINCE})——verify-result.md 缺「## 接口验证覆盖矩阵」段。` +
1098
+ `修复:跑 ${INIT_HINT} 幂等补段(已有正文不覆盖),再逐行填判定与证据。`
1099
+ )
1100
+ } else if (newPipeline && criticalLevel) {
1101
+ errors.push(
1102
+ `接口验证覆盖矩阵段缺失且判级 ${a.riskLevel}——critical 变更接口面对账面不可缺失(防删段绕过对账,FR-04)。` +
1103
+ `修复:跑 ${INIT_HINT} 幂等补段后逐行填判定与证据。`
1104
+ )
1105
+ } else {
1106
+ warnings.push(
1107
+ `verify-result.md 缺「## 接口验证覆盖矩阵」段${newPipeline ? '(非严格档非判级 critical,存量不强制)' : '(facts.apiFace 缺席——特性前回填的存量底稿)'}——可跑 ${INIT_HINT} 幂等补段。`
1108
+ )
1109
+ }
1110
+ return { ok: errors.length === 0, errors, warnings }
1111
+ }
1112
+
1113
+ // ── 零接口面分层(D-005):判级 critical → error(接口面不可静默为零——「变更有接口面」以
1114
+ // 判级 critical 为机械代理);非判级 critical → 零行为零打扰(brownfield 兼容)──
1115
+ const declaredFromRow = matrix.declaredRow ? matrix.declaredRow.declared : null
1116
+ const zeroFace = endpoints.length === 0 && declared === null && declaredFromRow === null
1117
+ if (zeroFace && criticalLevel) {
1118
+ errors.push(
1119
+ `判级 ${a.riskLevel} 且接口面为零(design 接口段解析零端点且无「本变更接口面:N 端点」声明行)——critical 变更接口面不可静默为零(D-005/FR-04)。` +
1120
+ `修复:design.md 补接口段表格(每端点一行 METHOD /path)或声明行「本变更接口面:N 端点」,重跑 ${INIT_HINT} 刷新矩阵段与 facts 后逐行填判定。`
1121
+ )
1122
+ }
1123
+ // 判级 critical × 声明 0 端点 → warning 复核(D-005 故障面条款:显式零声明仍可能是漏写)
1124
+ if (criticalLevel && (declared === 0 || declaredFromRow === 0)) {
1125
+ warnings.push(
1126
+ `判级 ${a.riskLevel} 且声明接口面为 0 端点——critical 变更声明零接口面请复核(D-005):若实际触碰接口,补 design 接口段表格后重跑 ${INIT_HINT} 刷新对账面。`
1127
+ )
1128
+ }
1129
+
1130
+ const clip = (s) => { const t = String(s || ''); return t.length > 40 ? t.slice(0, 40) + '…' : t }
1131
+ const rowLabel = (r) => `${r.method && r.path ? `${r.method} ${r.path}` : clip(r.endpoint || r.verdict)}`
1132
+ const judgedRows = [...rows]
1133
+ if (matrix.declaredRow) judgedRows.push({ ...matrix.declaredRow, endpoint: `本变更接口面:${matrix.declaredRow.declared} 端点(agent 声明)`, method: '', path: '', caseId: '', result: '' })
1134
+
1135
+ // ── 行级判定槽/锚点/non-testable 理由校验(missingEvidence 口径同 probe7 :896-903)──
1136
+ const unfilledRows = judgedRows.filter(r => r.unfilled)
1137
+ if (unfilledRows.length > 0) {
1138
+ errors.push(
1139
+ `接口验证覆盖矩阵有 ${unfilledRows.length} 行判定未填(四选一 covered/partial/uncovered/non-testable):${unfilledRows.map(rowLabel).join(';')}。` +
1140
+ `修复:编辑 verify-result.md 接口验证覆盖矩阵段,把 <待填:四选一> 替换为判定值。`
1141
+ )
1142
+ }
1143
+ const parseSet = new Set(endpoints.map(e => `${e.method} ${e.path}`))
1144
+ const anchorViolations = []
1145
+ for (const r of judgedRows) {
1146
+ if (r.verdict !== 'covered' && r.verdict !== 'partial') continue
1147
+ const e = String(r.evidence || '')
1148
+ // design接口表# 锚点解析级核对(防空指):提取的 METHOD /path 须全部命中解析产出集
1149
+ API_ANCHOR_DESIGN_API_RE.lastIndex = 0
1150
+ for (const m of e.matchAll(API_ANCHOR_DESIGN_API_RE)) {
1151
+ const key = `${m[1]} ${m[2]}`
1152
+ if (!parseSet.has(key)) anchorViolations.push(`${rowLabel(r)}:design接口表#${key} 未命中 design 接口段解析面(空指)`)
1153
+ }
1154
+ if (!apiEvidenceHasAnchorForm(e)) anchorViolations.push(`${rowLabel(r)}:证据缺用例依据锚点(五形态之一)`)
1155
+ }
1156
+ if (anchorViolations.length > 0) {
1157
+ errors.push(
1158
+ `接口验证覆盖矩阵有 ${anchorViolations.length} 项证据锚点缺失/空指` +
1159
+ `(covered/partial 证据须含锚点五形态之一:design接口表#METHOD /path(须命中 design 接口段解析面)/ 权限矩阵[角色×动作] / 契约表@行标识 / DDL@列名 / 载荷@构造点路径):${anchorViolations.join(';')}。` +
1160
+ `修复:在证据列补真实锚点(design接口表# 锚点须与 design.md 接口段端点一致,防编造端点)。`
1161
+ )
1162
+ }
1163
+ // non-testable 理由非空即合法(:900 先例「non-testable 证据须写一句理由」,理由空按违规计)
1164
+ const ntMissing = judgedRows.filter(r => r.verdict === 'non-testable' && (String(r.evidence || '').trim() === '' || String(r.evidence).trim() === MATRIX_EVIDENCE_TODO || String(r.evidence).trim().startsWith('<待填')))
1165
+ if (ntMissing.length > 0) {
1166
+ errors.push(
1167
+ `接口验证覆盖矩阵有 ${ntMissing.length} 行 non-testable 缺理由(non-testable 证据须写一句理由):${ntMissing.map(rowLabel).join(';')}。` +
1168
+ `修复:在证据列为 non-testable 端点写一句不适用理由。`
1169
+ )
1170
+ }
1171
+
1172
+ // ── covered 记账(D-005/D-008):分子=covered 端点行;有效分母=N−non-testable 行数;
1173
+ // N=解析端点数,解析零行按声明数(declaredRow 优先——矩阵实态,facts.declared 兜底)。
1174
+ // 缺覆盖清单解析集驱动:解析面端点 − covered 行命中 − non-testable 行命中(partial/
1175
+ // uncovered/unfilled 端点行不在 covered 命中集即自然进清单;探索行不进分母分子——其
1176
+ // uncovered 判定使之天然不计分子,且不计入 non-testable 扣减)。──
1177
+ if (!zeroFace) {
1178
+ const N = endpoints.length > 0 ? endpoints.length : (declaredFromRow != null ? declaredFromRow : (declared != null ? declared : 0))
1179
+ const nonTestableCount = rows.filter(r => r.verdict === 'non-testable').length
1180
+ const validDenominator = Math.max(0, N - nonTestableCount)
1181
+ const coveredCount = rows.filter(r => r.verdict === 'covered').length
1182
+ const coveredSet = new Set(rows.filter(r => r.verdict === 'covered' && r.method && r.path).map(r => `${r.method} ${r.path}`))
1183
+ const nonTestSet = new Set(rows.filter(r => r.verdict === 'non-testable' && r.method && r.path).map(r => `${r.method} ${r.path}`))
1184
+ const missingEndpoints = endpoints.filter(e => {
1185
+ const k = `${e.method} ${e.path}`
1186
+ return !coveredSet.has(k) && !nonTestSet.has(k)
1187
+ })
1188
+ if (coveredCount < validDenominator || missingEndpoints.length > 0) {
1189
+ const list = missingEndpoints.length > 0
1190
+ ? missingEndpoints.map(e => `${e.method} ${e.path}`).join(';')
1191
+ : `声明 ${N} 端点而 covered 端点行仅 ${coveredCount}(解析零行降级,无法逐条列端点——按声明行拆出每端点行填判定)`
1192
+ errors.push(
1193
+ `接口验证覆盖矩阵覆盖不足:有效分母 ${validDenominator}(N=${N} − non-testable ${nonTestableCount}),covered 分子 ${coveredCount}——缺覆盖端点:${list}。` +
1194
+ `修复:补验证用例/冒烟步骤后把端点行改 covered 并填五形态锚点;不适用端点改 non-testable 并写一句理由;确未覆盖的走「## 移交项(结构化)」承载并保持 partial/uncovered(FR-04)。`
1195
+ )
1196
+ }
1197
+ // 声明与解析并存以解析为准并注记(骨架 :1573 漂移信号同款口径,数据面 facts.apiFace)
1198
+ if (declared !== null && endpoints.length > 0 && declared !== endpoints.length) {
1199
+ warnings.push(
1200
+ `声明与解析并存(声明 ${declared} 端点 / 解析 ${endpoints.length} 端点)——以解析为准,差异需复核(design 接口段与声明行不同步的漂移信号,D-005)。`
1201
+ )
1202
+ }
1203
+ }
1204
+
1205
+ // ── 移交联动(probe7 条件④ :1068-1073 同款形态):partial/uncovered 端点行>0 且
1206
+ // facts.handover 零有效行 → error(已覆盖不足且有未验证端点无去向不可静默);有行 →
1207
+ // 放行,blocking 是否封顶归 validatePassEligibility 条件②(④管有去向/②管去向级)。
1208
+ // 探索行属 uncovered 子集计入(探索性验证不算覆盖,同样须有去向);facts 缺失 →
1209
+ // fail-closed 按零有效行处理(factsExpected 已在入口把关,此处只防 facts 消失面)。──
1210
+ const partialRows = [
1211
+ ...rows.filter(r => r.verdict === 'partial' || r.verdict === 'uncovered'),
1212
+ ...(matrix.declaredRow && (matrix.declaredRow.verdict === 'partial' || matrix.declaredRow.verdict === 'uncovered')
1213
+ ? [{ ...matrix.declaredRow, endpoint: `本变更接口面:${matrix.declaredRow.declared} 端点(agent 声明)`, method: '', path: '' }] : []),
1214
+ ]
1215
+ if (partialRows.length > 0) {
1216
+ const factsMissing = !a.facts || typeof a.facts !== 'object'
1217
+ const handoverItems = !factsMissing && a.facts.handover && Array.isArray(a.facts.handover.items)
1218
+ ? a.facts.handover.items.filter(it => it && typeof it === 'object') : []
1219
+ if (handoverItems.length === 0) {
1220
+ errors.push(
1221
+ `接口验证覆盖矩阵含 ${partialRows.length} 行 partial/uncovered 且「## 移交项(结构化)」零有效行(facts.handover)——接口未验证端点必须有移交去向:${partialRows.map(rowLabel).join(';')}。` +
1222
+ `修复:在 verify-result.md 补「## 移交项(结构化)」有效行(任意 severity 均可,blocking 级另受 PASS 封顶约束),或修正矩阵判定(实际已覆盖的行改 covered),或降级结论为 PASS WITH NOTES 承载。` +
1223
+ (factsMissing
1224
+ ? `另:verify-facts.json 缺失/不可读而 factsExpected=true,fail-closed 按零有效行处理——重跑 verify-probes(${INIT_HINT} 幂等刷新底稿)。`
1225
+ : '')
1226
+ )
1227
+ } else {
1228
+ // 逐行关联 advisory(R-05 第一版,D-003 同款分工):行标识未命中任何 handover 条目文本
1229
+ // → console.warn 攒实证(文本命中弱关联,先 advisory 攒数据),不阻断不进 errors。
1230
+ const itemTexts = handoverItems.map(it => `${it.item || ''}|${it.condition || ''}`)
1231
+ for (const r of partialRows) {
1232
+ const keys = [r.method && r.path ? `${r.method} ${r.path}` : '', r.endpoint].map(k => String(k || '').trim()).filter(Boolean)
1233
+ const hit = keys.some(k => itemTexts.some(t => t.includes(k)))
1234
+ if (!hit) console.warn(`ℹ️ [advisory] 接口端点 ${rowLabel(r)} 的移交去向未在 handover 条目中命中——建议条目文本含该端点标识(advisory,攒实证;不阻断)`)
1235
+ }
1236
+ }
1237
+ }
1238
+
1239
+ // ── advisory:消费面子行缺失(D-006 第一版)——consumerHints 有归类(design 清单启发式,
1240
+ // 变更级归类)而矩阵零子行 → warning 列端点(不阻断、不进 errors)。──
1241
+ const hintKinds = Object.keys(consumerHints).filter(k => Array.isArray(consumerHints[k]) && consumerHints[k].length > 0)
1242
+ if (hintKinds.length > 0 && matrix.subRowCount === 0 && (endpoints.length > 0 || (declaredFromRow != null ? declaredFromRow : declared) > 0)) {
1243
+ const list = endpoints.length > 0
1244
+ ? endpoints.slice(0, 8).map(e => `${e.method} ${e.path}`).join(';') + (endpoints.length > 8 ? '…' : '')
1245
+ : `声明 ${declaredFromRow != null ? declaredFromRow : declared} 端点(解析零行降级)`
1246
+ warnings.push(
1247
+ `[advisory] 消费端归类在场(${hintKinds.join('/')},facts.consumerHints——design 清单启发式)但矩阵零消费端子行——建议为接口端点补子行细分承接面(两空格缩进「↳ <消费端>:」形态,D-006,不阻断):${list}`
1248
+ )
1249
+ }
1250
+
1251
+ // ── advisory:写端点权限矩阵缺行(D-007/FR-06,文案照 design §5)——apiFace.writeEndpoints
1252
+ // 中端点未在权限矩阵段(壳内提取:design 段头含 权限/角色)命中(路径原串或模板基径)
1253
+ // 且无「无权限约束」豁免(design 行级/矩阵行级/权限段整体)→ warning。──
1254
+ const permText = String(a.permSectionText || '')
1255
+ const designLines = String(a.designText || '').replace(/\r\n/g, '\n').split('\n')
1256
+ const exemptTexts = [
1257
+ ...designLines,
1258
+ ...rows.map(r => `${r.endpoint}|${r.caseId}|${r.result}|${r.evidence}`),
1259
+ ]
1260
+ if (!permText.includes('无权限约束')) {
1261
+ for (const ep of writeEndpoints) {
1262
+ const base = String(ep.path).split(/[{:]/)[0]
1263
+ const hit = permText.includes(ep.path) || (base.length > 1 && permText.includes(base))
1264
+ if (hit) continue
1265
+ const rowExempt = exemptTexts.some(t => t.includes('无权限约束') && (t.includes(ep.path) || (base.length > 1 && t.includes(base))))
1266
+ if (!rowExempt) {
1267
+ warnings.push(
1268
+ `[advisory] 写端点 ${ep.method} ${ep.path} 未在权限矩阵声明——补行或显式豁免(表缺行会让派生框架继承你的洞)(D-007/FR-06,不阻断)`
1269
+ )
1270
+ }
1271
+ }
1272
+ }
1273
+
1274
+ return { ok: errors.length === 0, errors, warnings }
1275
+ }
1276
+
1277
+ /**
1278
+ * design.md 权限矩阵段提取(task-05 壳层辅助,D-007 表间完备性数据面):段头(#/##/###)
1279
+ * 含 权限/角色 关键词的段体合并文本(含段头行——命中判据宽松侧,advisory 无害)。零段 → ''。
1280
+ */
1281
+ function extractPermissionMatrixText(designMd) {
1282
+ const text = String(designMd || '').replace(/\r\n/g, '\n')
1283
+ if (!text) return ''
1284
+ const out = []
1285
+ let collecting = false
1286
+ for (const line of text.split('\n')) {
1287
+ const h = line.match(/^#{1,3}\s+(.*)$/)
1288
+ if (h) {
1289
+ collecting = /权限|角色/.test(h[1])
1290
+ if (collecting) out.push(h[1])
1291
+ continue
1292
+ }
1293
+ if (collecting) out.push(line)
1294
+ }
1295
+ return out.join('\n')
1296
+ }
1297
+
1298
+ /**
1299
+ * 接口验证覆盖矩阵 validator 注册壳(与 validateAcceptanceMatrix 同三参签名同构,D-010:
1300
+ * 注册进 contracts.verify.validators 即覆盖 gates / machine-interface 等全部 runValidators
1301
+ * 调用方,gates.js 零改动)。壳内取数组装(IO/MD 槽解析全在壳层,判定是
1302
+ * judgeApiCoverageMatrix 纯函数——X-09 双层形态):
1303
+ * - tasks/ 不存在(quick 会话/旧变更)或 verify-result.md 未落盘(中间步骤)→ no-op
1304
+ * - factsExpected=false(存量未跑管线,resolveFactsExpected 同源口径)→ no-op 零行为
1305
+ * - verify-facts.json(apiFace/consumerHints/handover,缺失容 null——fail-closed 归纯函数)
1306
+ * - 矩阵段 MD 槽解析(X-05 防篡改锚点:锚定当前文档实态)
1307
+ * - detectChangeRisk 判级(与 validatePassEligibility 壳同款 design/plan 双文件输入)
1308
+ * - design.md 权限矩阵段提取(fs 只读,壳非纯函数)
1309
+ */
1310
+ export function validateApiCoverageMatrix(cwd, changeName, context = {}) {
1311
+ const { specRoot } = context
1312
+ const errors = []
1313
+ const warnings = []
1314
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
1315
+ // 无 TaskCard(tasks/ 不存在)→ 不校验(brownfield 零行为,validateAcceptanceMatrix 同门)
1316
+ if (!existsSync(join(changeDir, 'tasks'))) return { ok: true, errors, warnings }
1317
+ // verify-result.md 中间步骤未落盘 → 不校验(末步引擎存在性规则兜底)
1318
+ const verifyResultPath = join(changeDir, 'verify-result.md')
1319
+ if (!existsSync(verifyResultPath)) return { ok: true, errors, warnings }
1320
+ // 存量未跑管线(无 facts 且非严格档)→ 矩阵段/apiFace 俱不可得,零行为零打扰
1321
+ if (!resolveFactsExpected(changeDir)) return { ok: true, errors, warnings }
1322
+
1323
+ const facts = readFactsForEligibility(changeDir)
1324
+ const designText = readIfExists(join(changeDir, 'design.md'))
1325
+ const riskProfile = detectChangeRisk({
1326
+ designContent: designText,
1327
+ planContent: readIfExists(join(changeDir, 'plan.md')),
1328
+ })
1329
+ return judgeApiCoverageMatrix({
1330
+ matrix: extractApiCoverageMatrixSlots(readFileSync(verifyResultPath, 'utf8')),
1331
+ apiFace: facts && facts.apiFace && typeof facts.apiFace === 'object' ? facts.apiFace : null,
1332
+ consumerHints: facts && facts.consumerHints && typeof facts.consumerHints === 'object' ? facts.consumerHints : null,
1333
+ facts,
1334
+ factsExpected: true,
1335
+ strict: isIrStrictVerifyChange(changeDir),
1336
+ riskLevel: riskProfile ? riskProfile.level : null,
1337
+ changeName,
1338
+ permSectionText: extractPermissionMatrixText(designText),
1339
+ designText,
1340
+ })
1341
+ }
1342
+
1343
+ // ============ PASS 封顶事实面 validator(2026-09-17-pass-cap-semantics task-02 / D-001@v2 / D-010 / D-011) ============
1344
+ //
1345
+ // 「要不要集成证据」(validateVerifyOutputs 的 requiresEvidence)与「能不能写 PASS」(本
1346
+ // validator)解耦:结论=PASS 时四个已知未验证区任一在场即 error——
1347
+ // ① 集成实测未跑(facts.integrationRan=not-ran,D-006 判定表)
1348
+ // ② blocking 级移交项在场(facts.handover[].severity,D-005@v2——④管「有去向」、②管「去向是否 blocking」)
1349
+ // ③ db 脚本未声明执行(verify 时点文件集 ∩ db/**/*.sql 对账 facts.dbScriptDeclarations,D-012)
1350
+ // ④ 矩阵含 partial/uncovered 且移交项零有效行(facts.matrixPartialRows × facts.handover)
1351
+ // 附加:runtimeEndpointExcluded=true 且判级 integration/deployment-critical 且 handover 零行
1352
+ // → 计入①事实面(D-004/FR-03,仅判级时计入)。
1353
+ // ⑤ 接口冒烟未跑(facts.smokeRan≠ran,仅判级 integration/deployment-critical 时计入,
1354
+ // FR-02/D-002@v1——2026-09-17-api-coverage-smoke task-03;不设 handover 豁免子句)。
1355
+ //
1356
+ // 分层纪律(D-011):事实生产走 facts 管线(verify-probes backfillFactsFromMdAndTests 首次
1357
+ // backfill 主路径无条件产出,X-08 时序——gates 收尾前置 backfill 先于 runValidators);本壳只做
1358
+ // 取数组装,判定是 evaluatePassEligibility 纯函数(零 MD 解析、零 IO——MD 锚点防篡改兜底并入
1359
+ // checkProbeConsistency 抽查面属 task-03)。双源 fail-closed:factsExpected=true 而 facts 缺失
1360
+ // (篡改/管线故障)→ 全条件按触发拦下;factsExpected=false(存量未跑 --init)→ 兼容 ok 不误伤。
1361
+
1362
+ /**
1363
+ * 读 <changeDir>/verify-facts.json(缺失/不可解析/非对象 → null)——与 checkProbeConsistency
1364
+ * 的 readVerifyFacts(verify-postcheck.js)同口径「有 facts」判别。不静态 import
1365
+ * verify-postcheck/verify-probes(verify-probes 顶层 await 动态 import 回指本模块,静态依赖
1366
+ * 会在其 TLA 上成环死锁;分层单向,全局硬约束 3),就地等价实现。
1367
+ */
1368
+ function readFactsForEligibility(changeDir) {
1369
+ const factsPath = join(changeDir, 'verify-facts.json')
1370
+ if (!existsSync(factsPath)) return null
1371
+ try {
1372
+ const parsed = JSON.parse(readFileSync(factsPath, 'utf8'))
1373
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null
1374
+ } catch { return null }
1375
+ }
1376
+
1377
+ /**
1378
+ * factsExpected 判定(D-011 双源 fail-closed 的「应写」侧):isIrStrictVerifyChange(严格档
1379
+ * 变更删 facts.json 另由 checkProbeConsistency error 级 MD 锚点兜底拦,通道 gates probeBlocked)
1380
+ * || verify-facts.json 在场(readVerifyFacts 同口径,含探针子节判别的「有 facts」面)。
1381
+ * factsExpected=false(存量未跑管线)→ 封顶兼容 skip 不误伤。
1382
+ * @param {string} changeDir 变更目录
1383
+ * @returns {boolean}
1384
+ */
1385
+ export function resolveFactsExpected(changeDir) {
1386
+ return isIrStrictVerifyChange(changeDir) || readFactsForEligibility(changeDir) !== null
1387
+ }
1388
+
1389
+ /** facts.handover 有效行数(producer parseHandoverRows 已滤占位/表头行;无 facts/handover → 0) */
1390
+ function countFactsHandoverItems(changeDir) {
1391
+ const facts = readFactsForEligibility(changeDir)
1392
+ const items = facts && facts.handover && Array.isArray(facts.handover.items) ? facts.handover.items : []
1393
+ return items.filter(it => it && typeof it === 'object').length
1394
+ }
1395
+
1396
+ /**
1397
+ * PASS 资格事实面封顶纯函数(X-09 双层形态的判定层:零 MD 解析、零 IO,消费 task-01 契约字段)。
1398
+ * @param {{
1399
+ * conclusion?: string|null,
1400
+ * facts?: object|null,
1401
+ * factsExpected?: boolean,
1402
+ * changeRiskProfile?: { level?: string }|null,
1403
+ * changeName?: string,
1404
+ * dbScriptCandidates?: string[],
1405
+ * }} [args] dbScriptCandidates = 壳内取数的 verify 时点文件集(design 清单 ∪ worktree 变更)∩ db 目录任意层级 .sql
1406
+ * @returns {{ ok: boolean, errors: string[], triggered: Array<{ fact: 'integration-not-run'|'blocking-handover-present'|'db-script-undeclared'|'matrix-partial-no-handover'|'smoke-not-run', detail: string }> }}
1407
+ */
1408
+ export function evaluatePassEligibility(args) {
1409
+ const errors = []
1410
+ const triggered = []
1411
+ const a = args || {}
1412
+ // factsExpected=false(存量未跑管线)→ 沿用存量兼容口径 ok 不误伤(checkProbeConsistency 同款边界)
1413
+ if (!a.factsExpected) return { ok: true, errors, triggered }
1414
+ // 封顶只管 PASS:结论 ≠ PASS(FAIL / PASS WITH NOTES / 未识别未填)→ 直接 ok
1415
+ if (a.conclusion !== 'PASS') return { ok: true, errors, triggered }
1416
+
1417
+ const factsMissing = !a.facts || typeof a.facts !== 'object'
1418
+ const facts = factsMissing ? {} : a.facts
1419
+ const handoverItems = (facts.handover && Array.isArray(facts.handover.items)) ? facts.handover.items : []
1420
+ const blockingRows = handoverItems.filter(it => it && it.severity === 'blocking')
1421
+ const criticalLevel = a.changeRiskProfile && ['integration-critical', 'deployment-critical'].includes(a.changeRiskProfile.level)
1422
+ const NOTES_FIX = '改写结论为 PASS WITH NOTES 并在「## 移交项(结构化)」表格如实分行(类型枚举 env-blocked/manual-acceptance/db-script/other)'
1423
+
1424
+ // ── factsExpected=true 而 facts 缺失 → 双源 fail-closed:全条件按触发(D-011),附重跑 verify-probes 出路(R-07)──
1425
+ if (factsMissing) {
1426
+ errors.push(
1427
+ `[pass-eligibility] verify-facts.json 缺失/不可读但 factsExpected=true(严格档或底稿曾在场后消失——疑似删除/管线故障),按双源 fail-closed 全条件触发拦下。` +
1428
+ `出路:重跑 verify-probes(sillyspec verify-probes --change ${a.changeName || '<变更名>'} --init 幂等刷新底稿后重跑 --done 回填),或${NOTES_FIX}降级承载。`
1429
+ )
1430
+ }
1431
+
1432
+ // ① 集成实测未跑(时序口径 X-01:validator 时点只认已落盘 quality-scan 记录,被拦出路=重跑质量扫描步或降级 NOTES,不算失败)
1433
+ if (factsMissing || facts.integrationRan === 'not-ran') {
1434
+ triggered.push({ fact: 'integration-not-run', detail: factsMissing ? 'facts 缺失(fail-closed 按未跑处理)' : 'facts.integrationRan=not-ran' })
1435
+ errors.push(
1436
+ `[fact integration-not-run] 集成实测未跑(${factsMissing ? 'verify-facts.json 缺失,fail-closed 按未跑处理' : 'facts.integrationRan=not-ran'})——结论=PASS 须集成实测已跑(D-006 判定表:quality-scan 实测记录(skip 除外)或跨层回执条目)。` +
1437
+ `出路:重跑质量扫描步落实测记录,或提供跨层实测回执后重跑 verify-probes 刷新 facts;仍未跑则${NOTES_FIX}承载(不算失败)。`
1438
+ )
1439
+ }
1440
+
1441
+ // ② blocking 级移交项在场(blocking 移交项与结论 PASS 互斥)
1442
+ if (factsMissing || blockingRows.length > 0) {
1443
+ const list = blockingRows.map(it => `${it.type || '未知类型'}|${String(it.item || '').slice(0, 60)}`).join(';')
1444
+ triggered.push({ fact: 'blocking-handover-present', detail: factsMissing ? 'facts 缺失(fail-closed)' : `${blockingRows.length} 条 blocking 行:${list}` })
1445
+ errors.push(
1446
+ `[fact blocking-handover-present] ${factsMissing
1447
+ ? '移交项 blocking 级行无法核对(verify-facts.json 缺失,fail-closed 按在场触发)'
1448
+ : `移交项含 ${blockingRows.length} 条 blocking 级行——承认有必须兜底的未竟事项即不是 PASS(④管「有去向」、②管「去向是否 blocking」)`}。` +
1449
+ `触发行:${factsMissing ? '(facts 缺失,无法核对——fail-closed)' : list}。修复:${NOTES_FIX};确属 advisory 的按降级文法「(降级:<理由>,依据 <file:line 或 D-xxx>)」显式降级。`
1450
+ )
1451
+ }
1452
+
1453
+ // ③ db 脚本未声明执行(D-012:verify 时点按声明面/diff 对账,apply/archive 兜底门归 task-04;零连库)
1454
+ const candidates = Array.isArray(a.dbScriptCandidates) ? a.dbScriptCandidates : []
1455
+ const declared = factsMissing ? [] : (Array.isArray(facts.dbScriptDeclarations) ? facts.dbScriptDeclarations : [])
1456
+ const undeclared = candidates.filter(f => !declared.includes(f))
1457
+ if (undeclared.length > 0) {
1458
+ triggered.push({ fact: 'db-script-undeclared', detail: `${undeclared.join('、')} 未进 facts.dbScriptDeclarations` })
1459
+ errors.push(
1460
+ `[fact db-script-undeclared] db 脚本在 verify 时点文件集(design 清单 ∪ worktree 变更)∩ db/**/*.sql 中但未声明执行:${undeclared.join('、')}。` +
1461
+ `修复:已对目标库执行则在 verify-result.md 补声明(「已对目标库执行:db/<file>.sql」或回执槽 command 含该文件)后重跑 verify-probes 刷新 facts;未执行则${NOTES_FIX}承载(db-script 类恒 blocking)。`
1462
+ )
1463
+ }
1464
+
1465
+ // ④ 矩阵含 partial/uncovered 且移交项零有效行(任意 severity——部分实现必须有移交去向)
1466
+ if (factsMissing || ((facts.matrixPartialRows || 0) > 0 && handoverItems.length === 0)) {
1467
+ triggered.push({ fact: 'matrix-partial-no-handover', detail: factsMissing ? 'facts 缺失(fail-closed)' : `matrixPartialRows=${facts.matrixPartialRows} 且 handover 零有效行` })
1468
+ errors.push(
1469
+ `[fact matrix-partial-no-handover] 验收×测试覆盖矩阵含 ${factsMissing ? '(facts 缺失,无法核对——fail-closed)' : `${facts.matrixPartialRows} 行 partial/uncovered`} 且「## 移交项(结构化)」零有效行——部分实现必须有移交去向。修复:${NOTES_FIX}承载(任意 severity 均可,blocking 级另受条件②约束)。`
1470
+ )
1471
+ }
1472
+
1473
+ // ── 附加条件(D-004/FR-03,仅判级 integration/deployment-critical 时计入①事实面):
1474
+ // Runtime Evidence「服务端点」行自声明「不涉及」(facts.runtimeEndpointExcluded,X-18 文法)
1475
+ // 且移交项零行——端点不得以「不涉及」免检,须真实回执或 handover 承载。
1476
+ if (criticalLevel && (factsMissing || facts.runtimeEndpointExcluded === true) && handoverItems.length === 0) {
1477
+ triggered.push({
1478
+ fact: 'integration-not-run',
1479
+ detail: factsMissing ? 'facts 缺失(fail-closed,runtimeEndpointExcluded 不可证伪)' : 'facts.runtimeEndpointExcluded=true 且 handover 零行',
1480
+ })
1481
+ errors.push(
1482
+ `[fact integration-not-run] 判级 ${a.changeRiskProfile.level} 且 Runtime Evidence 服务端点行自声明「不涉及」(facts.runtimeEndpointExcluded=true)且无移交项承载——端点不得以「不涉及」免检(D-004)。` +
1483
+ `出路:提供真实端点回执(跨层实测)后重跑 verify-probes,或${NOTES_FIX}承载。`
1484
+ )
1485
+ }
1486
+
1487
+ // ── ⑤ smoke 冒烟未跑(2026-09-17-api-coverage-smoke task-03 / FR-02 / D-002@v1,判级
1488
+ // 限定——criticalLevel 在场才计入,runtimeEndpointExcluded 附加条件同款形态):判级
1489
+ // integration/deployment-critical 且 facts.smokeRan !== 'ran'(producer 自 quality-scan
1490
+ // 记录 smokeResult 段推导)→ triggered 加枚举 smoke-not-run。不设 handover 豁免子句——
1491
+ // advisory handover 在场不构成 smoke 缺失的 PASS 豁免(smoke 缺失的合法出路只有配
1492
+ // commands.smoke 复跑质量扫描步 / 降级 PASS WITH NOTES 移交承载两条);blocking handover
1493
+ // 在场由条件②独立拦下(出口=NOTES),两条件各自触发不互斥。factsMissing 沿双源
1494
+ // fail-closed 同族口径按触发处理(smokeRan 不可证伪);facts 在场但字段不在场(升级
1495
+ // 过渡期存量 facts,producer 重新 backfill 前)同 !== 'ran' 触发——fail-closed 侧,防
1496
+ // 「判级 critical 未跑冒烟静默 PASS」。非判级(unit-sufficient 等)任意 smokeRan 零行为。
1497
+ if (criticalLevel && (factsMissing || facts.smokeRan !== 'ran')) {
1498
+ const smokeDetail = factsMissing
1499
+ ? 'facts 缺失(fail-closed,smokeRan 不可证伪按未跑处理)'
1500
+ : `facts.smokeRan=${facts.smokeRan == null ? '不在场(按未跑处理)' : facts.smokeRan}`
1501
+ triggered.push({ fact: 'smoke-not-run', detail: `${smokeDetail},判级 ${a.changeRiskProfile.level}` })
1502
+ const configHint = facts.smokeRan === 'not-configured'
1503
+ ? '(not-configured:先在 local.yaml 配置键 commands.smoke,如 smoke: "npm run smoke")' : ''
1504
+ errors.push(
1505
+ `[fact smoke-not-run] 接口冒烟未跑(${smokeDetail})且判级 ${a.changeRiskProfile.level}——critical 变更结论=PASS 须接口冒烟绿跑(facts.smokeRan=ran,FR-02/D-002)。` +
1506
+ `出路:①配 commands.smoke 并复跑质量扫描步${configHint};②降级承载——${NOTES_FIX}(不算失败)。`
1507
+ )
1508
+ }
1509
+
1510
+ return { ok: errors.length === 0, errors, triggered }
1511
+ }
1512
+
1513
+ /**
1514
+ * 事实③文件集取数(D-012,纯声明面/diff 对账零连库):design.md 文件清单
1515
+ * (change-list.parseFileChangeListDetailed——design-facts.validateDesignFileList 同源解析器)
1516
+ * ∪ worktree changed files(worktree meta baseHash..HEAD diff ∪ porcelain——checkExecuteCodeEvidence
1517
+ * 同款取数形态,与 task-review resolveAttributionDiffFiles 内 resolveVerifyChangedFiles 同源口径;
1518
+ * 本模块不静态 import verify-postcheck/verify-probes,TLA 动态环,就地等价实现)∩ db 目录任意层级 .sql。
1519
+ * @param {{ cwd: string, changeName: string, specRoot?: string|null, changeDir: string }} opts
1520
+ * @returns {string[]} 排序后的候选 db 脚本(posix 相对路径)
1521
+ */
1522
+ function collectDbScriptCandidates({ cwd, changeName, specRoot, changeDir }) {
1523
+ const files = new Set()
1524
+ const add = (p) => {
1525
+ const n = String(p || '').trim().replace(/^"|"$/g, '').replace(/\\/g, '/')
1526
+ if (n && n !== '.sillyspec' && !n.startsWith('.sillyspec/')) files.add(n)
1527
+ }
1528
+ // design 清单半边(design.md 缺失/无清单段 → 空集,fail-soft,diff 半边仍覆盖)
1529
+ try {
1530
+ for (const entry of parseFileChangeListDetailed(join(changeDir, 'design.md'))) add(entry && entry.path)
1531
+ } catch { /* 解析异常退 diff 半边 */ }
1532
+ // worktree 变更半边:meta 双根候选(specBase 优先 + cwd/.sillyspec 兜底,_readWorktreeMeta 样板)
1533
+ try {
1534
+ const specBase = specRoot || join(cwd, '.sillyspec')
1535
+ const metaCandidates = [
1536
+ join(specBase, '.runtime', 'worktrees', changeName, 'meta.json'),
1537
+ join(cwd, '.sillyspec', '.runtime', 'worktrees', changeName, 'meta.json'),
1538
+ ]
1539
+ let meta = null
1540
+ for (const p of metaCandidates) {
1541
+ if (existsSync(p)) {
1542
+ try { meta = JSON.parse(readFileSync(p, 'utf8')) } catch { meta = null }
1543
+ if (meta) break
1544
+ }
1545
+ }
1546
+ if (meta && meta.baseHash) {
1547
+ const gitDir = (meta.worktreePath && meta.mode !== 'in-place-fallback' && existsSync(meta.worktreePath))
1548
+ ? meta.worktreePath
1549
+ : cwd
1550
+ const diff = gitTry(gitDir, ['diff', '--name-only', `${meta.baseHash}..HEAD`])
1551
+ if (diff.ok) for (const f of diff.out.split('\n')) add(f)
1552
+ const status = gitTry(gitDir, ['status', '--porcelain'])
1553
+ if (status.ok) {
1554
+ for (const line of status.out.split('\n')) {
1555
+ if (!line.trim()) continue
1556
+ add(line.slice(3).trim().split(' -> ').pop())
1557
+ }
1558
+ }
1559
+ }
1560
+ } catch { /* meta/git 取数失败退 design 清单半边(fail-soft) */ }
1561
+ return [...files].filter(p => /^db\/.+\.sql$/i.test(p)).sort()
1562
+ }
1563
+
1564
+ /**
1565
+ * PASS 封顶 validator 注册壳(与 validateAcceptanceMatrix 同三参签名同构,D-010:注册进
1566
+ * contracts.verify.validators 即覆盖 gates / machine-interface 等全部 runValidators 调用方,
1567
+ * gates.js 零改动)。壳内取数组装:verify-result.md 结论(槽优先,legacy 回退)、
1568
+ * verify-facts.json(缺失容 null)、detectChangeRisk 判级、事实③文件集;判定调
1569
+ * evaluatePassEligibility 纯函数。
1570
+ */
1571
+ export function validatePassEligibility(cwd, changeName, context = {}) {
1572
+ const { specRoot } = context
1573
+ const errors = []
1574
+ const warnings = []
1575
+ const changeDir = resolveChangeDir(cwd, changeName, specRoot)
1576
+ // 变更目录缺失 → 引擎 manifest 存在性规则兜底(validateVerifyOutputs 同名 error),此处 no-op
1577
+ if (!existsSync(changeDir)) return { ok: true, errors, warnings }
1578
+ // verify-result.md 中间步骤未落盘 → no-op(同 validateAcceptanceMatrix:存在性归引擎末步规则)
1579
+ const verifyResultPath = join(changeDir, 'verify-result.md')
1580
+ if (!existsSync(verifyResultPath)) return { ok: true, errors, warnings }
1581
+
1582
+ const conclusion = resolveVerifyConclusion(readFileSync(verifyResultPath, 'utf8'))
1583
+ const facts = readFactsForEligibility(changeDir)
1584
+ const result = evaluatePassEligibility({
1585
+ conclusion,
1586
+ facts,
1587
+ factsExpected: isIrStrictVerifyChange(changeDir) || facts !== null,
1588
+ changeRiskProfile: detectChangeRisk({
1589
+ designContent: readIfExists(join(changeDir, 'design.md')),
1590
+ planContent: readIfExists(join(changeDir, 'plan.md')),
1591
+ }),
1592
+ changeName,
1593
+ dbScriptCandidates: collectDbScriptCandidates({ cwd, changeName, specRoot, changeDir }),
1594
+ })
1595
+ return { ok: result.ok, errors: result.errors, warnings }
1596
+ }
1597
+
873
1598
  /**
874
1599
  * 计算归档目标目录名:保持原变更名不变,直接移入 archive/。
875
1600
  */
@@ -1064,7 +1789,12 @@ const contracts = {
1064
1789
  allowedTo: ['archive'],
1065
1790
  // validateAcceptanceMatrix:探针 7 矩阵槽 fail-closed(2026-09-14-acceptance-test-matrix FR-02),
1066
1791
  // 注册进 validator 链即覆盖 gates / machine-interface 等全部 runValidators 调用方(gates.js 零改动)。
1067
- validators: [validateVerifyOutputs, validateAcceptanceMatrix],
1792
+ // validatePassEligibility:PASS 封顶事实面(2026-09-17-pass-cap-semantics task-02 / D-001@v2 /
1793
+ // D-010)——注册即覆盖同上;回退 = 移除本注册行(纯加法,未触发四条件行为零变化)。
1794
+ // validateApiCoverageMatrix:接口验证覆盖矩阵对账门(2026-09-17-api-coverage-smoke task-05 /
1795
+ // FR-04~FR-06,与探针 7 并排互补的独立章节)——注册即覆盖同上;回退 = 移除本注册行(纯加法,
1796
+ // factsExpected=false / 无矩阵段的存量变更零行为)。
1797
+ validators: [validateVerifyOutputs, validateAcceptanceMatrix, validatePassEligibility, validateApiCoverageMatrix],
1068
1798
  },
1069
1799
  archive: {
1070
1800
  stage: 'archive',