sillyspec 3.28.9 → 3.28.11

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 (55) 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/package.json +1 -1
  6. package/src/change-list.js +73 -20
  7. package/src/change-risk-profile.js +51 -3
  8. package/src/config-schema.js +51 -0
  9. package/src/contract-matrix.js +84 -5
  10. package/src/cross-repo-reconcile.js +134 -0
  11. package/src/design-facts.js +94 -18
  12. package/src/docs-check.js +14 -10
  13. package/src/docs-debt.js +7 -2
  14. package/src/docs-gate.js +42 -15
  15. package/src/endpoint-extractor.js +50 -2
  16. package/src/foreign-declared.js +25 -16
  17. package/src/hooks/worktree-guard.js +75 -2
  18. package/src/index.js +147 -2
  19. package/src/knowledge-classify.js +20 -1
  20. package/src/machine-interface.js +26 -0
  21. package/src/probe7-anchor-check.js +87 -0
  22. package/src/progress/stage-machine.js +34 -5
  23. package/src/progress.js +1 -1
  24. package/src/quicklog.js +240 -5
  25. package/src/run/command.js +25 -2
  26. package/src/run/complete-handlers.js +172 -1
  27. package/src/run/complete.js +143 -11
  28. package/src/run/gate-snapshot.js +372 -2
  29. package/src/run/gates.js +130 -12
  30. package/src/run/prompt.js +166 -7
  31. package/src/run/shared.js +81 -1
  32. package/src/scope-audit.js +101 -31
  33. package/src/stage-contract-spec.js +14 -0
  34. package/src/stage-contract.js +314 -8
  35. package/src/stage-review-checklist.js +3 -1
  36. package/src/stage-review.js +137 -2
  37. package/src/stages/archive.js +9 -5
  38. package/src/stages/brainstorm.js +7 -1
  39. package/src/stages/cmd-existence.js +9 -3
  40. package/src/stages/execute.js +62 -8
  41. package/src/stages/knowledge.js +24 -3
  42. package/src/stages/plan-postcheck.js +74 -10
  43. package/src/stages/plan.js +8 -1
  44. package/src/stages/quick.js +5 -5
  45. package/src/stages/verify.js +3 -2
  46. package/src/sync-noise.js +57 -1
  47. package/src/sync.js +77 -4
  48. package/src/task-review.js +84 -29
  49. package/src/verify-facts-schema.js +78 -9
  50. package/src/verify-postcheck.js +439 -68
  51. package/src/verify-probes.js +1156 -45
  52. package/src/worktree-apply.js +323 -8
  53. package/src/worktree-deps.js +24 -1
  54. package/src/worktree.js +274 -10
  55. package/src/write-audit.js +55 -0
@@ -4,8 +4,9 @@
4
4
  *
5
5
  * verify-probes.md 模板定义六个探针,agent 此前逐条手跑 grep/递归查找/git 对账再手工拼表格。
6
6
  * 本模块把纯机械的四个探针命令化(语义判断的留 agent,输出里显式标注):
7
- * 探针1 未实现标记扫描:design §6 清单的具体文件逐行 grep TODO/FIXME/尚未实现 等
8
- * 探针3 测试覆盖:逐 task 按 allowed_paths 定位模块目录,递归找测试文件(co-located tests/ 陷阱)
7
+ * 探针1 未实现标记扫描:design §6 清单的具体文件逐行 grep TODO/FIXME/尚未实现 等 probe1-noqa
8
+ * 探针3 测试覆盖:逐 task 按 allowed_paths 定位模块目录,递归找测试文件(co-located tests/ 陷阱
9
+ * + JVM src/test/<lang> 同包镜像根——Maven/Gradle 布局测试与 main 侧永不 co-located)
9
10
  * 探针5 API 契约对账:复用 contract-matrix.verifyApiParity(endpoints.json × 前端调用)+ 表格渲染
10
11
  * 探针6 删除对账:git diff --name-status HEAD 的 D/R × design 声明操作三态判定
11
12
  * 探针7 验收×测试覆盖矩阵:task 卡 acceptance 自解析(jsYaml,string/array 双形态)+ 双源结构
@@ -13,7 +14,7 @@
13
14
  * 关键词命中提示(命中≠判定,不参与门禁;2026-09-14-acceptance-test-matrix)
14
15
  * 探针2(关键词提取半语义)/探针3.4 集成盲区/3.5 断言抽查/探针4(决策追踪语义)留 agent。
15
16
  *
16
- * verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。
17
+ * verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。 probe1-noqa
17
18
  * 结论走「结论枚举:」固定槽行(刀③)——占位符不含枚举词,槽未填 extractVerifyConclusionSlot
18
19
  * 返回 '' 即判不过,骨架不能直接过门(与 symbol-impact 骨架同款防偷懒语义)。
19
20
  * P3b 增量:①章节标题行末尾 claims 层标注(可复跑探针/确定性检查/人工判断,纯后缀不新增行);
@@ -21,7 +22,7 @@
21
22
  * 供事后独立复跑审计;重复 --init 覆盖为最近一次 init 快照)。
22
23
  */
23
24
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs'
24
- import { join, dirname, basename } from 'path'
25
+ import { join, dirname, basename, resolve, isAbsolute } from 'path'
25
26
  import jsYaml from 'js-yaml'
26
27
  import { gitQuiet } from './git-helper.js'
27
28
  import {
@@ -29,13 +30,47 @@ import {
29
30
  } from './verify-facts-schema.js'
30
31
  import { parseFileChangeListDetailed } from './change-list.js'
31
32
  import { parseDecisions } from './decision-distill.js'
32
- import { parseAllowedPaths } from './stages/plan-postcheck.js'
33
+ import { parseAllowedPaths, parseRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
33
34
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
34
35
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
35
36
  import { resolveSpecDir, resolveRuntimeRoot, detectWorktreeSpecDrift } from './run/shared.js'
36
37
 
37
- const TODO_MARKER_RE = /尚未实现|TODO|FIXME|HACK|XXX/
38
- const TEST_FILE_RE = /test|spec/i
38
+ // 探针1 未实现标记匹配(坑 probe1-literal-false-positive,2026-09-15 复盘实证:16 命中全
39
+ // 字面误报——TODO_FLAG_TODO 业务常量、「XXX完成处置」中文占位模板)。口径分三级:
40
+ // - 尚未实现:中文短语高置信,保持子串匹配; /* probe1-noqa */
41
+ // - TODO/FIXME/HACK:ASCII 标识符边界匹配——TODO_FLAG_TODO / parseHackArgs 等标识符内部不再 /* probe1-noqa */
42
+ // 命中(两侧任一边贴 [A-Za-z0-9_] 即视为标识符成分);
43
+ // - XXX:边界匹配且前或后紧邻 CJK 表意字符即排除——「XXX完成处置」「订单XXX号」类中文占位 /* probe1-noqa */
44
+ // 模板不再命中(独立代码注释 `XXX:` / `// XXX fix` 紧邻标点空白,仍命中)。 /* probe1-noqa */
45
+ const CJK_IDEOGRAPH_CLASS = '\\u4e00-\\u9fff\\u3400-\\u4dbf'
46
+ const TODO_ASCII_MARKER_RE = new RegExp(`(^|[^A-Za-z0-9_])(?:TODO|FIXME|HACK)(?![A-Za-z0-9_])`) /* probe1-noqa */
47
+ const XXX_MARKER_RE = new RegExp(`(^|[^A-Za-z0-9_${CJK_IDEOGRAPH_CLASS}])XXX(?![A-Za-z0-9_${CJK_IDEOGRAPH_CLASS}])`) /* probe1-noqa */
48
+ export function isUnimplementedMarkerLine(line) {
49
+ if (line.includes('尚未实现')) return true /* probe1-noqa */
50
+ if (TODO_ASCII_MARKER_RE.test(line)) return true
51
+ if (XXX_MARKER_RE.test(line)) return true
52
+ return false
53
+ }
54
+ // 测试文件名判定(坑 probe3-testname-false-positive,2026-09-15 EHS 生产实证:model 目录下
55
+ // TestData.java 数据夹具被旧 /test|spec/i 子串命中 → task-01 假绿「找到 1 个测试文件」;
56
+ // specification.md / contest.css 同族反向噪音)。三路命中,任一即测试文件:
57
+ // - 分隔符分词(按非字母数字切段)含 test/tests/spec/specs —— foo.test.js、test_utils.py、foo-spec.ts
58
+ // - 裸词文件名(去扩展名即上述词,大小写归一)—— test.js、Tests.java
59
+ // - 驼峰末段后缀 Test/Tests/Spec/Specs/IT(Java/Kotlin 惯例 FooTest/FooIT)——只认末位驼峰段:
60
+ // RpFlowEngineTest 命中;TestData/TestUtil/TestMain(Test 是首段=夹具命名惯势)不命中
61
+ // 取向:漏检罕见命名(FooTestCase/TestFoo 前缀式)→ ⚠️ 落 agent 手查(fail-visible 可接受);
62
+ // 假绿掩盖真缺测是 fail-hidden,更糟——宁紧勿松。
63
+ const TEST_NAME_TOKENS = new Set(['test', 'tests', 'spec', 'specs'])
64
+ const TEST_NAME_SUFFIXES = new Set(['Test', 'Tests', 'Spec', 'Specs', 'IT'])
65
+ export function isTestFileName(name) {
66
+ const stem = String(name || '').replace(/\.[^.]+$/, '')
67
+ if (!stem) return false
68
+ if (TEST_NAME_TOKENS.has(stem.toLowerCase())) return true
69
+ const tokens = stem.toLowerCase().split(/[^a-z0-9]+/).filter(Boolean)
70
+ if (tokens.some(t => TEST_NAME_TOKENS.has(t))) return true
71
+ const lastHump = stem.match(/(?:[A-Z][a-z0-9]*|[A-Z]+)$/)
72
+ return !!lastHump && TEST_NAME_SUFFIXES.has(lastHump[0])
73
+ }
39
74
  const PROBE1_MAX_MATCHES = 200
40
75
 
41
76
  /**
@@ -89,7 +124,7 @@ function findTestFiles(rootDir, cwd, cap = 10) {
89
124
  const full = join(d, e.name)
90
125
  if (e.isDirectory()) {
91
126
  if (!skip.has(e.name)) walk(full)
92
- } else if (e.isFile() && TEST_FILE_RE.test(basename(e.name))) {
127
+ } else if (e.isFile() && isTestFileName(e.name)) {
93
128
  found.push(full.split('\\').join('/').replace(cwd.split('\\').join('/').replace(/\/$/, '') + '/', ''))
94
129
  if (found.length >= cap) return
95
130
  }
@@ -99,7 +134,609 @@ function findTestFiles(rootDir, cwd, cap = 10) {
99
134
  return found
100
135
  }
101
136
 
102
- // ── 探针 7(验收×测试覆盖矩阵,2026-09-14-acceptance-test-matrix FR-01)常量与纯函数 ──
137
+ // ── 探针 8(载荷字段契约对账,2026-09-16 EHS 二次独立复核驱动)常量与纯函数 ──
138
+ // 5 个 P1 里 4 处是字段/载荷错位(leaderUserId↔rpLeaderUserId 三处 Jackson 静默丢弃、小程序
139
+ // 缺发 reportOrgId、sourceShdId↔safelyHiddenId、report_org_name NOT NULL 边界)——探针 5 只对
140
+ // 账 method+path(URL 级),载荷级错位零覆盖。本探针三面启发式比对(advisory,命中≠结论):
141
+ // 前端请求载荷键 × 后端 Java 字段(归一化覆盖)∪ 子串疑似错位配对 ∪ SQL NOT NULL 列缺送核对。
142
+ const PROBE8_HEADING = '#### 探针 8:载荷字段契约对账(advisory)'
143
+ // 服务端填充/标准列豁免(NOT NULL 但不该由前端送)
144
+ const PROBE8_SERVER_FILLED_RE = /^(id|create_by|create_date|update_by|update_date|remarks|dept_id|tenant_id|del_flag)$/i
145
+
146
+ /**
147
+ * 归一化键:小写 + 剥全部分隔线(snake/连字符)——两侧同构(rp_category / rpCategory /
148
+ * rp-category → rpcategory)。注意不能「小写后再 camel 化」(会单侧引入大写不对称)。
149
+ */
150
+ const normFieldKey = (s) => String(s || '').trim().toLowerCase().replace(/[_-]/g, '')
151
+
152
+ /** Java private 字段名提取(全大写常量 TODO_FLAG_TODO 排除——EHS 探针1 同款教训) */
153
+ export function extractJavaFields(text) {
154
+ const names = new Set()
155
+ const re = /private\s+(?:static\s+|final\s+)*[\w.$<>[\]]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*[;=]/g
156
+ let m
157
+ while ((m = re.exec(String(text || ''))) !== null) {
158
+ if (!/^[A-Z0-9_]+$/.test(m[1])) names.add(m[1])
159
+ }
160
+ return names
161
+ }
162
+
163
+ /** SQL CREATE TABLE 行的 NOT NULL 业务列(审计列豁免) */
164
+ export function extractSqlNotNullColumns(text) {
165
+ const cols = new Set()
166
+ for (const line of String(text || '').split('\n')) {
167
+ const m = line.match(/^\s*`?([a-zA-Z_][a-zA-Z0-9_]*)`?\s+(?:varchar|char|int|integer|bigint|decimal|datetime|date|timestamp|text|double|float)\b[^,]*NOT NULL\b/i)
168
+ if (m && !PROBE8_SERVER_FILLED_RE.test(m[1])) cols.add(m[1])
169
+ }
170
+ return cols
171
+ }
172
+
173
+ /**
174
+ * 请求载荷键提取:apiFetch/request/axios/$http/fetch( 调用行后 8 行窗口内的对象键
175
+ * (key: 后随值起始为字面量/对象/数组/null/undefined/负号——排除 case/三元等形态)。
176
+ * UI 本地态对象(请求区外)天然不收。
177
+ */
178
+ export function extractPayloadKeys(text) {
179
+ const keys = new Set()
180
+ const lines = String(text || '').split('\n')
181
+ for (let i = 0; i < lines.length; i++) {
182
+ if (!/\b(apiFetch|request|axios|\$http|fetch)\s*\(/.test(lines[i])) continue
183
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
184
+ for (const m of lines[j].matchAll(/(?:^|[{,(\s])([A-Za-z_][A-Za-z0-9]*)\s*:\s(?:["'`\d{[tfn-]|null\b|undefined\b)/g)) {
185
+ keys.add(m[1])
186
+ }
187
+ }
188
+ }
189
+ return keys
190
+ }
191
+
192
+ // ── 探针 8 契约维度(task-01,2026-09-16 跨层契约探针扩展)──
193
+ // design.md 的契约类章节(接口定义/数据模型等)字段表是「文档契约面」——既有三面(Java 字段/
194
+ // SQL NOT NULL/前端载荷键)对不上「文档 vs 实现」漂移:契约改了代码没跟上(orphan)、契约必填
195
+ // 前端根本没送(EHS 实证小程序缺发 reportOrgId 必填被拒同族)。纯 advisory 不升硬门。
196
+ const PROBE8_CONTRACT_SECTION_RE = /接口定义|数据模型|接口契约|字段/
197
+ const PROBE8_FIELD_HEADER_RE = /字段|field/i
198
+ const PROBE8_REQUIRED_CELL_RE = /必填|required|※/i
199
+ const PROBE8_FIELD_IDENT_RE = /^[A-Za-z_][A-Za-z0-9_]*$/
200
+
201
+ /** markdown 切章节(##/### 标题行;# 是题名、#### 归正文,均不切),返回 [{heading, lines}] */
202
+ function splitMdSections(text) {
203
+ const sections = []
204
+ let cur = null
205
+ for (const line of String(text || '').split('\n')) {
206
+ const h = line.match(/^#{2,3}\s+(.*?)\s*#*\s*$/)
207
+ if (h) {
208
+ cur = { heading: h[1].trim(), lines: [] }
209
+ sections.push(cur)
210
+ } else if (cur) cur.lines.push(line)
211
+ }
212
+ return sections
213
+ }
214
+
215
+ /** 表格行切列(剥首尾竖线与空白) */
216
+ const splitMdRow = (line) => String(line || '').trim().replace(/^\|/, '').replace(/\|$/, '').split('|').map(c => c.trim())
217
+
218
+ /** markdown 表格分隔行(|---|---| 形态:剥竖线后仅剩 :/-/空白即认定) */
219
+ const isMdDividerRow = (line) => {
220
+ const s = String(line || '').replace(/\|/g, '').trim()
221
+ return s !== '' && /^[:\s-]+$/.test(s)
222
+ }
223
+
224
+ /**
225
+ * 契约字段首列清洗:剥反引号与类型注记尾缀——`` `fieldName` `` / `fieldName(String)` /
226
+ * `fieldName int` 等形态统一取标识符部分(类型混进字段名会破坏归一化比对)。
227
+ */
228
+ const stripContractFieldName = (cell) => {
229
+ const s = String(cell || '').replace(/`/g, '').trim()
230
+ const paren = s.match(/^([A-Za-z_][A-Za-z0-9_]*)\s*[((]/)
231
+ if (paren) return paren[1]
232
+ const tail = s.match(/^([A-Za-z_][A-Za-z0-9_]*)\s+(?:String|Integer|Int|Long|Double|Float|Boolean|Date|BigDecimal|varchar|char|int|bigint|decimal|datetime|timestamp|text)\b/i)
233
+ if (tail) return tail[1]
234
+ return s
235
+ }
236
+
237
+ /**
238
+ * design.md 契约面解析(task-01):契约类章节(标题含 接口定义/数据模型/接口契约/字段 之一)内
239
+ * 表头首个数据列含「字段/Field」的表格 → {name=章节名, fields, required}。防误判:文件清单表
240
+ * (首列「操作」)/风险表(首列「#」)不入面;数据行不足 2 行的噪声表跳过;章节含
241
+ * `<!-- probe8-skip -->` 整章跳过计 skippedSections(非契约字段表的逃生门)。
242
+ * @param {string} designPath
243
+ * @returns {{contracts: Array<{name: string, fields: Set<string>, required: Set<string>}>, skippedSections: number}|null}
244
+ */
245
+ export function parseDesignContracts(designPath) {
246
+ if (!existsSync(designPath)) return null
247
+ let text
248
+ try { text = readFileSync(designPath, 'utf8') } catch { return null }
249
+ const contracts = []
250
+ let skippedSections = 0
251
+ for (const sec of splitMdSections(text)) {
252
+ if (!PROBE8_CONTRACT_SECTION_RE.test(sec.heading)) continue
253
+ if (sec.lines.some(l => l.includes('<!-- probe8-skip -->'))) { skippedSections++; continue }
254
+ let i = 0
255
+ while (i < sec.lines.length) {
256
+ const line = sec.lines[i]
257
+ if (!line.includes('|') || !isMdDividerRow(sec.lines[i + 1] || '')) { i++; continue }
258
+ // 表头首个数据列(首个非空列,剥反引号/空白)含 字段/Field 才入面;「操作」/「#」首列
259
+ // (文件清单表/风险表)显式排除——防表头措辞漂移误收。
260
+ const headerFirst = (splitMdRow(line).find(c => c !== '') || '').replace(/`/g, '').trim()
261
+ let j = i + 2
262
+ const dataRows = []
263
+ while (j < sec.lines.length && sec.lines[j].includes('|') && sec.lines[j].trim() !== '') { dataRows.push(sec.lines[j]); j++ }
264
+ if (PROBE8_FIELD_HEADER_RE.test(headerFirst) && headerFirst !== '操作' && headerFirst !== '#'
265
+ && dataRows.length >= 2) {
266
+ const fields = new Set()
267
+ const required = new Set()
268
+ for (const row of dataRows) {
269
+ const cells = splitMdRow(row)
270
+ const fname = stripContractFieldName(cells[0])
271
+ if (!fname || !PROBE8_FIELD_IDENT_RE.test(fname)) continue // 非标识符首列(子说明行)不收
272
+ fields.add(fname)
273
+ // required=该行任一列含 必填|required|※
274
+ if (cells.some(c => PROBE8_REQUIRED_CELL_RE.test(c))) required.add(fname)
275
+ }
276
+ if (fields.size > 0) contracts.push({ name: sec.heading, fields, required })
277
+ }
278
+ i = j
279
+ }
280
+ }
281
+ return { contracts, skippedSections }
282
+ }
283
+
284
+ /** design 是否声明提交端点:「接口定义」章节内含 POST/PUT 行(纯查询契约不苛求前端送全必填) */
285
+ function designHasSubmitEndpoint(text) {
286
+ for (const sec of splitMdSections(text)) {
287
+ if (!/接口定义/.test(sec.heading)) continue
288
+ if (sec.lines.some(l => /\b(POST|PUT)\b/.test(l))) return true
289
+ }
290
+ return false
291
+ }
292
+
293
+ /**
294
+ * 探针 8 主体:design 清单三面文件(Java/SQL/前端)→ 双根(主仓 ∪ worktree)∪ 跨仓注册仓根
295
+ * 读取 → 归一化比对。advisory:所有输出是「候选」不是结论(UI 本地态键/服务端填充列会自然
296
+ * 出现在差异里,agent 逐条复核——口径注记随渲染输出)。
297
+ * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string }} args
298
+ * @returns {{applicable, backendFieldCount, feKeyCount, notNullCount, javaFileCount, sqlFileCount, feFileCount,
299
+ * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
300
+ * contractCount: number, contractOrphans: Array<{fe, hint?}>, missingRequired: Array<{field, contract}>}}
301
+ */
302
+ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName }) {
303
+ const out = {
304
+ applicable: false, backendFieldCount: 0, feKeyCount: 0, notNullCount: 0,
305
+ javaFileCount: 0, sqlFileCount: 0, feFileCount: 0,
306
+ mispairs: [], feOnly: [], missingNotNull: [], notes: [],
307
+ contractCount: 0, contractOrphans: [], missingRequired: [],
308
+ }
309
+ if (!specBase || !changeName) return out
310
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
311
+ if (!existsSync(designPath)) return out
312
+ let registry = new Map()
313
+ try {
314
+ const yamlPath = join(specBase, 'local.yaml')
315
+ if (existsSync(yamlPath)) registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
316
+ } catch { /* 注册表不可读 → 跨仓条目落未注册注记 */ }
317
+ const detailed = parseFileChangeListDetailed(designPath, { repoKeys: [...registry.keys()] })
318
+
319
+ const readEntry = (e) => {
320
+ // NEW: 待建前缀剥离(坑 probe1-new-prefix-miss 同款):design 清单的 NEW: 是新建意图标记,
321
+ // 文件系统实体无前缀——按目标路径读。
322
+ const probePath = String(e.path).replace(/^NEW:\s*/, '')
323
+ // 未注册跨仓前缀兜底识别(change-list 只对注册 key 落 e.repo;未注册 key 整条 path 保留
324
+ // cross-repo: 前缀原样)——现身「未注册」注记而非静默不可读。
325
+ const crPrefix = probePath.match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
326
+ if (e.repo || crPrefix) {
327
+ const key = e.repo || (crPrefix && crPrefix[1])
328
+ const relPath = e.repo ? probePath : crPrefix[2]
329
+ const raw = registry.get(key)
330
+ if (!raw) { out.notes.push(`repo「${key}」未在 local.yaml repos 注册——该仓前端文件未进探针 8 比对`); return null }
331
+ const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
332
+ try { return readFileSync(join(root, relPath), 'utf8') } catch { out.notes.push(`跨仓文件不可读:${key}:${relPath}`); return null }
333
+ }
334
+ // 主仓 ∪ worktree 双根(与探针 1 同款回退)
335
+ for (const base of [cwd, wtRoot].filter(Boolean)) {
336
+ try { return readFileSync(join(base, probePath), 'utf8') } catch { /* 试下一根 */ }
337
+ }
338
+ return null
339
+ }
340
+
341
+ const backendFields = new Set()
342
+ const notNullCols = new Set()
343
+ const feKeys = new Set()
344
+ for (const e of detailed) {
345
+ if (!e.path || e.path.startsWith('.sillyspec/')) continue
346
+ const text = readEntry(e)
347
+ if (text == null) continue
348
+ if (e.path.endsWith('.java')) {
349
+ out.javaFileCount++
350
+ for (const f of extractJavaFields(text)) backendFields.add(f)
351
+ } else if (e.path.endsWith('.sql')) {
352
+ out.sqlFileCount++
353
+ for (const c of extractSqlNotNullColumns(text)) notNullCols.add(c)
354
+ } else if (/\.(js|jsx|ts|tsx)$/.test(e.path)) {
355
+ out.feFileCount++
356
+ for (const k of extractPayloadKeys(text)) feKeys.add(k)
357
+ }
358
+ }
359
+ // 后端零面(无 Java 无 SQL)→ 不适用(纯前端/文档变更不打扰)
360
+ if (out.javaFileCount === 0 && out.sqlFileCount === 0) return out
361
+ out.applicable = true
362
+
363
+ const backendNorm = new Map() // norm → 原名
364
+ for (const f of backendFields) backendNorm.set(normFieldKey(f), f)
365
+ const feNorm = new Map()
366
+ for (const k of feKeys) feNorm.set(normFieldKey(k), k)
367
+ // 配对 token(camelCase/snake 切词;单字符段只保留 id)——子串关系抓 leaderUserId⊂rpLeaderUserId,
368
+ // token 重叠(Jaccard≥0.6)抓语义近形 punishedDutyUserId↔punishedLeaderUserId(EHS 二次复核
369
+ // 两类实证形态)。并列取长度差最小者(leaderUserId 优先配 rpLeaderUserId 而非更长的
370
+ // punishedLeaderUserId)。
371
+ const fieldTokens = (s) => String(s).replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/)
372
+ .map(t => t.toLowerCase()).filter(t => t.length > 1 || t === 'id')
373
+
374
+ for (const [nk, original] of feNorm) {
375
+ if (backendNorm.has(nk)) continue // 归一化覆盖(rpCategory ↔ rp_category)
376
+ const feToks = fieldTokens(original)
377
+ let best = null
378
+ let bestScore = 0
379
+ let bestLenPenalty = Infinity
380
+ for (const [bn, beOrig] of backendNorm) {
381
+ let score = 0
382
+ if ((bn.includes(nk) || nk.includes(bn)) && Math.min(bn.length, nk.length) >= 4) score = 1
383
+ else if (feToks.length >= 2) {
384
+ const bt = fieldTokens(beOrig)
385
+ if (bt.length >= 2) {
386
+ const inter = feToks.filter(t => bt.includes(t)).length
387
+ const union = new Set([...feToks, ...bt]).size
388
+ if (union > 0) score = inter / union
389
+ }
390
+ }
391
+ if (score <= 0) continue
392
+ const lenPenalty = Math.abs(bn.length - nk.length)
393
+ if (score > bestScore || (score === bestScore && lenPenalty < bestLenPenalty)) {
394
+ bestScore = score
395
+ bestLenPenalty = lenPenalty
396
+ best = beOrig
397
+ }
398
+ }
399
+ if (best && bestScore >= 0.6) out.mispairs.push({ fe: original, be: best })
400
+ else out.feOnly.push(original)
401
+ }
402
+ for (const col of notNullCols) {
403
+ const nc = normFieldKey(col)
404
+ if (!feNorm.has(nc) && !backendNorm.has(nc)) out.missingNotNull.push({ col })
405
+ }
406
+
407
+ // ── 契约维度(task-01,advisory 不升硬门):design.md 契约面(接口定义/数据模型章节字段表)
408
+ // × 前端载荷键第四面对账——文档 vs 实现漂移:契约外载荷键(候选契约滞后/跨层私加)+ 契约必填
409
+ // 漏发(EHS 小程序缺发 reportOrgId 被拒同族)。无契约面仅注记,既有三维度输出不动。──
410
+ const contractRes = parseDesignContracts(designPath)
411
+ const contracts = (contractRes && contractRes.contracts) || []
412
+ if (contracts.length > 0) {
413
+ out.contractCount = contracts.length
414
+ const contractUnion = new Map() // norm → 契约字段原名(hint 对账用原名)
415
+ for (const c of contracts) {
416
+ for (const f of c.fields) {
417
+ const nf = normFieldKey(f)
418
+ if (!contractUnion.has(nf)) contractUnion.set(nf, f)
419
+ }
420
+ }
421
+ // 契约外载荷键:已落 feOnly/mispairs 疑似面的 fe 键(归一正名键不扰)且契约面归一化未见。
422
+ // hint 用 fieldTokens token-Jaccard 对契约字段原名单独跑一遍,≥0.4 取最高分者(并列取长度
423
+ // 差最小——同 mispairs 择优口径),仅提示性可缺省。
424
+ const suspected = new Set(out.feOnly)
425
+ for (const p of out.mispairs) suspected.add(p.fe)
426
+ for (const [nk, original] of feNorm) {
427
+ if (contractUnion.has(nk) || !suspected.has(original)) continue
428
+ const feToks = fieldTokens(original)
429
+ let hint = null
430
+ let bestScore = 0
431
+ let bestLenPenalty = Infinity
432
+ if (feToks.length >= 2) {
433
+ for (const [cn, cOrig] of contractUnion) {
434
+ const ct = fieldTokens(cOrig)
435
+ if (ct.length < 2) continue
436
+ const inter = feToks.filter(t => ct.includes(t)).length
437
+ const union = new Set([...feToks, ...ct]).size
438
+ const score = union > 0 ? inter / union : 0
439
+ if (score <= 0) continue
440
+ const lenPenalty = Math.abs(cn.length - nk.length)
441
+ if (score > bestScore || (score === bestScore && lenPenalty < bestLenPenalty)) {
442
+ bestScore = score
443
+ bestLenPenalty = lenPenalty
444
+ hint = cOrig
445
+ }
446
+ }
447
+ }
448
+ if (hint && bestScore >= 0.4) out.contractOrphans.push({ fe: original, hint })
449
+ else out.contractOrphans.push({ fe: original })
450
+ }
451
+ // 必填漏发:仅 design「接口定义」章节声明提交端点(POST/PUT 行)时启用——查询型契约的
452
+ // required 不苛求前端送全(GET 无 body)。契约 required 归一化后 ∉ feNorm 全集即列。
453
+ let designText = ''
454
+ try { designText = readFileSync(designPath, 'utf8') } catch { /* 不可读 → 不启用 */ }
455
+ if (designHasSubmitEndpoint(designText)) {
456
+ for (const c of contracts) {
457
+ for (const f of c.required) {
458
+ if (!feNorm.has(normFieldKey(f))) out.missingRequired.push({ field: f, contract: c.name })
459
+ }
460
+ }
461
+ }
462
+ } else {
463
+ out.notes.push('design 无契约面——契约维度 skipped')
464
+ }
465
+ out.backendFieldCount = backendFields.size
466
+ out.feKeyCount = feKeys.size
467
+ out.notNullCount = notNullCols.size
468
+ return out
469
+ }
470
+
471
+ // ── 探针 9(守卫一致性,2026-09-15 EHS doSubmit 越权 P1 同族驱动)常量与纯函数 ──
472
+ // 背景:同实体 CRUD 守卫不一致(deleteOrder/withdraw 有开立人校验、doSubmit 全链无操作人
473
+ // 校验→任何登录用户可提交他人开立单)是编码式权限的典型漏洞形态——探针 5 只对账 URL 存在性、
474
+ // 探针 8 只对账字段契约,权限守卫面零机器覆盖,全靠 verify 人工走查(EHS 恰是走查盲区)。
475
+ // 本探针(advisory,⚠️ 不阻断):同文件同实体变更方法组内有守卫/无守卫并存 → 定向复核提示。
476
+ // 宁漏勿误(R-01/R-02):≥2 方法才成组、纯标识符弱信号不计、只扫 design 清单 .java 文件。
477
+ const PROBE9_HEADING = '#### 探针 9:守卫一致性(advisory)'
478
+ // 变更动词前缀集(design §1 定稿)——方法名剥可选引导词后以前缀动词开头即变更方法
479
+ const PROBE9_MUTATION_VERBS = new Set(['submit', 'delete', 'remove', 'withdraw', 'update', 'handle', 'confirm', 'reject', 'audit', 'save', 'cancel', 'approve'])
480
+ // 引导前缀词(doSubmit/trySubmit 形态——EHS 真实方法名 doSubmit 的动词在第二词段)
481
+ const PROBE9_LEAD_WORDS = new Set(['do', 'try'])
482
+ // 方法签名行正则(design §1 定稿):访问修饰符 + 类型段(泛型/数组/逗号空格)+ 方法名 + (
483
+ // (构造器无「类型 空格 名」形态天然不命中;无访问修饰符的接口默认方法不收——宁漏勿误)
484
+ const PROBE9_METHOD_SIG_RE = /(?:public|private|protected)\s+[\w<>\[\],. ]+\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/
485
+ // 类名剥常见后缀(「实体取全类名」路径用:OrderController → Order)
486
+ const PROBE9_CLASS_SUFFIX_RE = /(?:Controller|RestController|Service|ServiceImpl|Mapper|Repository|Api|Manager|Handler)$/
487
+
488
+ /**
489
+ * Java 注释等长掩码(参照 endpoint-extractor.js stripCommentsKeepLength 思路在探针内自实现,
490
+ * 不跨模块 import——行注释/块注释替换为空格保留换行,字符串/char 字面量内容原样不动)。
491
+ * FR-02:注释里的守卫文档示例(// userId.equals(...) 之类)掩码后不再误报。
492
+ */
493
+ function probe9MaskCommentsKeepLength(text) {
494
+ const src = String(text || '')
495
+ const out = src.split('')
496
+ let st = 'normal' // normal | block | dq | sq
497
+ for (let i = 0; i < src.length; i++) {
498
+ const c = src[i]
499
+ const n = src[i + 1]
500
+ if (st === 'normal') {
501
+ if (c === '/' && n === '/') {
502
+ // 行注释:掩到行尾(换行符本身保留)
503
+ let j = i
504
+ while (j < src.length && src[j] !== '\n') { out[j] = ' '; j++ }
505
+ i = j - 1
506
+ } else if (c === '/' && n === '*') {
507
+ out[i] = ' '; out[i + 1] = ' '
508
+ st = 'block'; i++
509
+ } else if (c === '"') st = 'dq'
510
+ else if (c === "'") st = 'sq'
511
+ } else if (st === 'block') {
512
+ if (c === '*' && n === '/') { out[i] = ' '; out[i + 1] = ' '; st = 'normal'; i++ }
513
+ else if (c !== '\n') out[i] = ' '
514
+ } else {
515
+ // 字符串/char 内:转义跳过;闭合引号回 normal(内容原样保留——字符串不动)
516
+ if (c === '\\') i++
517
+ else if ((st === 'dq' && c === '"') || (st === 'sq' && c === "'")) st = 'normal'
518
+ }
519
+ }
520
+ return out.join('')
521
+ }
522
+
523
+ /** 驼峰切词(doSubmit → [do, Submit];submitOrder → [submit, Order];snake/连字符一并切段) */
524
+ const probe9SplitHumps = (s) => String(s || '').replace(/([a-z0-9])([A-Z])/g, '$1 $2').split(/[^A-Za-z0-9]+/).filter(Boolean)
525
+
526
+ /**
527
+ * 类实体名词(「实体取全类名」路径):首个 class/interface 声明名剥 Controller/Service 等
528
+ * 常见后缀后取尾段驼峰名词(PolluteRpOrderController → order、OrderController → order)——
529
+ * 纯动词方法名(submit/withdraw/handle/doSubmit)剥词后无从判实体,以类实体兜底归组(EHS
530
+ * 真实形态:deleteOrder 与 doSubmit 同在 PolluteRpOrderController,正是要聚上的一组)。
531
+ * @returns {{key: string, display: string}|null} key=小写归一键;类名不可解析 → null
532
+ */
533
+ function probe9ClassEntity(sourceText) {
534
+ const m = String(sourceText || '').match(/\b(?:class|interface)\s+([A-Za-z_][A-Za-z0-9_]*)/)
535
+ if (!m) return null
536
+ const bare = m[1].replace(PROBE9_CLASS_SUFFIX_RE, '') || m[1]
537
+ const words = probe9SplitHumps(bare)
538
+ return words.length > 0 ? { key: words[words.length - 1].toLowerCase(), display: words[words.length - 1] } : null
539
+ }
540
+
541
+ /**
542
+ * 方法名 → 实体键:驼峰切词后从头部剥引导词(do/try)与变更动词词段。
543
+ * - 首个有效词段既非引导词也非动词(getOrder 的 get)→ null(非变更方法,查询面不收)
544
+ * - 剥词后剩余词段非空 → 尾段驼峰名词为实体(submitOrder/deleteOrder → Order)
545
+ * - 剥词后空/纯动词(submit/doSubmit/handle)→ 类实体兜底(类名无从判 → null 跳过)
546
+ * @returns {{key: string, display: string}|null}
547
+ */
548
+ function probe9EntityOfMethodName(name, classEntity) {
549
+ const words = probe9SplitHumps(name)
550
+ let i = 0
551
+ let sawVerb = false
552
+ while (i < words.length) {
553
+ const w = words[i].toLowerCase()
554
+ if (PROBE9_LEAD_WORDS.has(w)) { i++; continue }
555
+ if (PROBE9_MUTATION_VERBS.has(w)) { sawVerb = true; i++; continue }
556
+ break
557
+ }
558
+ if (!sawVerb) return null
559
+ if (i >= words.length) return classEntity || null
560
+ const tail = words[words.length - 1]
561
+ return { key: tail.toLowerCase(), display: tail }
562
+ }
563
+
564
+ /**
565
+ * 内部聚类(含单方法实体组与行数组——runProbe9 统计「单方法组 skipped」注记、构造签名前
566
+ * 3 行上下文用;导出面 clusterMutationMethods 只回 ≥2 方法组)。
567
+ * @returns {{lines: string[], groups: Array<{entity, methods}>, singletonCount: number}}
568
+ */
569
+ function probe9ClusterInternal(sourceText) {
570
+ const normalized = String(sourceText || '').replace(/\r\n/g, '\n')
571
+ const lines = normalized.split('\n')
572
+ const sigs = []
573
+ for (let i = 0; i < lines.length; i++) {
574
+ const m = lines[i].match(PROBE9_METHOD_SIG_RE)
575
+ if (m) sigs.push({ name: m[1], sigLine: i })
576
+ }
577
+ const cls = probe9ClassEntity(normalized)
578
+ const byKey = new Map() // 实体键(小写归一) → {entity(首见原形), methods}
579
+ sigs.forEach((s, i) => {
580
+ const ent = probe9EntityOfMethodName(s.name, cls)
581
+ if (!ent) return
582
+ // 方法体 = 签名行到下一签名行前(含签名行;文件尾方法到 EOF,类尾近似)。下一签名前的
583
+ // 注解块(@PreAuthorize 等)归属下一方法——从体尾回收,防注解内 hasRole( 等字样误判
584
+ // 上一方法有守卫(冒烟实证:@PreAuthorize("hasRole('admin')") 划进上一方法体误中角色判定)。
585
+ let endIdx = i + 1 < sigs.length ? sigs[i + 1].sigLine : lines.length
586
+ while (endIdx > s.sigLine && /^\s*@/.test(lines[endIdx - 1] || '')) endIdx--
587
+ if (!byKey.has(ent.key)) byKey.set(ent.key, { entity: ent.display, methods: [] })
588
+ byKey.get(ent.key).methods.push({
589
+ name: s.name,
590
+ startLine: s.sigLine + 1,
591
+ endLine: endIdx,
592
+ body: lines.slice(s.sigLine, endIdx).join('\n'),
593
+ })
594
+ })
595
+ const all = [...byKey.values()]
596
+ return { lines, groups: all.filter(g => g.methods.length >= 2), singletonCount: all.filter(g => g.methods.length === 1).length }
597
+ }
598
+
599
+ /**
600
+ * 聚类变更方法(探针 9 纯函数核心,design §1):方法签名行定位 → 变更方法实体归组 →
601
+ * 同文件同实体(大小写归一)≥2 方法才成组(单方法不成组,R-01 宁漏勿误)。
602
+ * @param {string} sourceText 单个 .java 文件全文
603
+ * @returns {Array<{entity: string, methods: Array<{name: string, startLine: number, endLine: number, body: string}>}>}
604
+ */
605
+ export function clusterMutationMethods(sourceText) {
606
+ return probe9ClusterInternal(sourceText).groups
607
+ }
608
+
609
+ // ── 四类守卫信号正则(design §2 / R-02:调用/比较形态优先,纯标识符弱信号不计)──
610
+ // ① 当前用户比对:当前用户获取调用形态(词后随 ( 或 . 链式起点——SecurityContext.getContext()
611
+ // 的 Holder 形态由 \w* 覆盖),或 createBy/create_by/openBy/userId 邻接 .equals( / == / !=
612
+ const PROBE9_CURRENT_USER_CALL_RE = /\b(?:getCurrent\w*|currentUser|getUserId|getLoginUser\w*|SecurityContext\w*|ThreadLocal\w*Util)\s*[.(]/
613
+ const PROBE9_OWNER_EQUALS_RE = /\b(?:createBy|create_by|openBy|userId)\s*\.\s*equals\s*\(/
614
+ const PROBE9_OWNER_EQ_RE = /\b(?:createBy|create_by|openBy|userId)\s*(?:==|!=)/
615
+ const PROBE9_OWNER_EQ_BACK_RE = /(?:==|!=)\s*[\w.\s()]{0,40}?\b(?:createBy|create_by|openBy|userId)\b/
616
+ // ② 角色判定:hasRole/isCompany*/checkRole 调用形态,或签名里 boolean manager/isManager/isAdmin 布尔参数
617
+ const PROBE9_ROLE_CALL_RE = /\b(?:hasRole\w*|isCompany\w*|checkRole\w*)\s*\(/
618
+ const PROBE9_ROLE_PARAM_RE = /\bboolean\s+(?:is)?(?:[Mm]anager|[Aa]dmin)\b/
619
+ // ③ 能力类调用:canHandle/checkPerm*/assert*Perm|Auth|Owner|User/validate*User 调用形态
620
+ const PROBE9_CAPABILITY_CALL_RE = /\b(?:canHandle|checkPerm\w*|assert\w*(?:Perm|Auth|Owner|User)\w*|validate\w*User\w*)\s*\(/
621
+ // ④ 注解式:签名上下文内权限注解(强信号,出现即中)
622
+ const PROBE9_ANNOTATION_RE = /@(?:PreAuthorize|RolesAllowed|SaCheckPermission|RequiresPermissions)\b/
623
+
624
+ /**
625
+ * 守卫信号检测(探针 9 纯函数,design §2):方法体先做等长注释掩码(注释里的文档示例不
626
+ * 误报),再测四类信号——命中任一即该方法「有守卫」;返回空数组 = 无守卫。
627
+ * @param {string} methodBody 方法体文本(含签名行)
628
+ * @param {string} [signatureContext] 签名前 3 行 + 签名行(④注解式与②布尔参数的检测面)
629
+ * @returns {string[]} 命中信号类别列表('当前用户比对' | '角色判定' | '能力类调用' | '注解式')
630
+ */
631
+ export function detectGuardSignals(methodBody, signatureContext = '') {
632
+ const body = probe9MaskCommentsKeepLength(methodBody)
633
+ const ctx = probe9MaskCommentsKeepLength(signatureContext)
634
+ const signals = []
635
+ if (PROBE9_CURRENT_USER_CALL_RE.test(body) || PROBE9_OWNER_EQUALS_RE.test(body)
636
+ || PROBE9_OWNER_EQ_RE.test(body) || PROBE9_OWNER_EQ_BACK_RE.test(body)) signals.push('当前用户比对')
637
+ if (PROBE9_ROLE_CALL_RE.test(body) || PROBE9_ROLE_PARAM_RE.test(ctx)) signals.push('角色判定')
638
+ if (PROBE9_CAPABILITY_CALL_RE.test(body)) signals.push('能力类调用')
639
+ if (PROBE9_ANNOTATION_RE.test(ctx)) signals.push('注解式')
640
+ return signals
641
+ }
642
+
643
+ /**
644
+ * 探针 9 主体:design 清单 .java 文件(主仓 ∪ worktree 双根 + 跨仓注册仓根回退,对齐探针 8
645
+ * readEntry 写法)逐文件聚类 + 信号比对——同实体组内有守卫与无守卫并存才进 inconsistentGroups
646
+ * (全有/全无不报,宁漏勿误;advisory:输出是定向复核提示不是结论)。无 .java → applicable=false
647
+ * + 不适用注记;.java 首行 `// probe9-skip` 整文件跳过;单文件解析异常 fail-soft 跳过不炸整体。
648
+ * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string }} args
649
+ * @returns {{applicable: boolean, javaFileCount: number, groupCount: number,
650
+ * inconsistentGroups: Array<{entity: string, guarded: string[], unguarded: string[], signals: Record<string, string[]>}>,
651
+ * notes: string[]}}
652
+ */
653
+ export function runProbe9GuardConsistency({ specBase, cwd, wtRoot = null, changeName }) {
654
+ const out = { applicable: false, javaFileCount: 0, groupCount: 0, inconsistentGroups: [], notes: [] }
655
+ if (!specBase || !changeName) return out
656
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
657
+ if (!existsSync(designPath)) return out
658
+ let registry = new Map()
659
+ try {
660
+ const yamlPath = join(specBase, 'local.yaml')
661
+ if (existsSync(yamlPath)) registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
662
+ } catch { /* 注册表不可读 → 跨仓条目落未注册注记 */ }
663
+ const detailed = parseFileChangeListDetailed(designPath, { repoKeys: [...registry.keys()] })
664
+
665
+ // 双根读取(对齐探针 8 readEntry:NEW: 前缀剥离、未注册跨仓前缀兜底、主仓 ∪ worktree 回退)
666
+ const readEntry = (e) => {
667
+ const probePath = String(e.path).replace(/^NEW:\s*/, '')
668
+ const crPrefix = probePath.match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
669
+ if (e.repo || crPrefix) {
670
+ const key = e.repo || (crPrefix && crPrefix[1])
671
+ const relPath = e.repo ? probePath : crPrefix[2]
672
+ const raw = registry.get(key)
673
+ if (!raw) { out.notes.push(`repo「${key}」未在 local.yaml repos 注册——该仓 Java 文件未进探针 9 比对`); return null }
674
+ const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
675
+ try { return readFileSync(join(root, relPath), 'utf8') } catch { out.notes.push(`跨仓文件不可读:${key}:${relPath}`); return null }
676
+ }
677
+ for (const base of [cwd, wtRoot].filter(Boolean)) {
678
+ try { return readFileSync(join(base, probePath), 'utf8') } catch { /* 试下一根 */ }
679
+ }
680
+ return null
681
+ }
682
+
683
+ let nonJavaCount = 0
684
+ let singletonCount = 0
685
+ for (const e of detailed) {
686
+ if (!e.path || e.path.startsWith('.sillyspec/')) continue
687
+ if (!e.path.endsWith('.java')) { nonJavaCount++; continue }
688
+ const text = readEntry(e)
689
+ if (text == null) continue
690
+ // 文件级豁免:首行 // probe9-skip 整文件跳过(守卫由上游统一拦截等形态的逃生门)
691
+ if (/^\s*\/\/\s*probe9-skip\b/.test(text)) {
692
+ out.notes.push(`${e.path}:首行 // probe9-skip 豁免——整文件跳过探针 9`)
693
+ continue
694
+ }
695
+ let clustered
696
+ try {
697
+ clustered = probe9ClusterInternal(text)
698
+ } catch (err) {
699
+ out.notes.push(`${e.path}:解析异常 fail-soft 跳过(${err && err.message ? err.message : err})`)
700
+ continue
701
+ }
702
+ out.javaFileCount++
703
+ singletonCount += clustered.singletonCount
704
+ for (const g of clustered.groups) {
705
+ out.groupCount++
706
+ const signals = {}
707
+ const guarded = []
708
+ const unguarded = []
709
+ for (const m of g.methods) {
710
+ // signatureContext = 签名前 3 行 + 签名行(④注解式/②布尔参数的检测面);窗口不跨
711
+ // 上一方法签名行——紧凑单行方法下上一方法的 @PreAuthorize 会串扰进本方法检测面
712
+ // (冒烟实证:相邻单行方法时无注解方法被注解行误判有守卫,用例 4 全守卫假象)。
713
+ const win = clustered.lines.slice(Math.max(0, m.startLine - 4), m.startLine)
714
+ // 只在除本方法签名行(窗口末行)外的行里找上一方法签名行(findIndex 会命中自身签名行)
715
+ const cut = win.slice(0, -1).findIndex((l) => PROBE9_METHOD_SIG_RE.test(l))
716
+ const ctx = (cut >= 0 ? win.slice(cut + 1) : win).join('\n')
717
+ const sig = detectGuardSignals(m.body, ctx)
718
+ signals[m.name] = sig
719
+ if (sig.length > 0) guarded.push(m.name)
720
+ else unguarded.push(m.name)
721
+ }
722
+ if (guarded.length > 0 && unguarded.length > 0) {
723
+ out.inconsistentGroups.push({ entity: g.entity, guarded, unguarded, signals })
724
+ }
725
+ }
726
+ }
727
+ if (out.javaFileCount === 0) {
728
+ out.notes.push(nonJavaCount > 0
729
+ ? `清单无 .java 文件(另有 ${nonJavaCount} 个非 Java 清单文件不在探针 9 扫描面)`
730
+ : '清单无 .java 文件')
731
+ return out
732
+ }
733
+ out.applicable = true
734
+ if (nonJavaCount > 0) out.notes.push(`${nonJavaCount} 个非 Java 清单文件不在探针 9 扫描面(v1 只扫 .java,design 非目标)`)
735
+ if (singletonCount > 0) out.notes.push(`单方法实体 ${singletonCount} 个不成组(≥2 方法才比对,宁漏勿误)`)
736
+ return out
737
+ }
738
+
739
+
103
740
  const PROBE7_HEADING = '#### 探针 7:验收×测试覆盖矩阵'
104
741
  // execute run id 格式(与 task-review.js isValidExecuteRunId 同口径锚定,防提示词注入/路径穿越)
105
742
  const PROBE7_EXEC_RUN_ID_RE = /^exec-\d{4}-\d{2}-\d{2}-\d{6}(?:-[a-z0-9]{1,8}){0,2}$/
@@ -137,7 +774,7 @@ export function parseTaskAcceptance(content) {
137
774
  /**
138
775
  * allowed_paths 条目是否测试形态(结构归属面):test/ 前缀,或文件名含 .test. / _test. / spec
139
776
  * 惯例((?:^|[._-])test(?:[._-]|$) 覆盖 foo.test.mjs / foo_test.js / test-foo.js 三形)。
140
- * 比探针 3 的存在性面(TEST_FILE_RE 子串宽松匹配)严格——这是 3/7 口径差异的落点。
777
+ * 比探针 3 的存在性面(isTestFileName 文件名形态判定,宽松存在性提示)严格——这是 3/7 口径差异的落点。
141
778
  */
142
779
  function isProbe7TestPath(p) {
143
780
  const posix = String(p).split('\\').join('/')
@@ -214,13 +851,20 @@ function extractAcceptanceTerms(text) {
214
851
  * 命中≠判定(R-03,不参与门禁)——只有存在命中的条目才进 hints(键 = acceptance 条目下标)。
215
852
  * 测试文件读取双根回退(cwd → worktree 根,坑 probe1-worktree-path-blind / probe3 双根同族:
216
853
  * apply 前新测试只在 worktree)。读不到的文件计 null 跳过。
854
+ * task-05 / FR-09:双根扩多根——并入 local.yaml repos 注册的跨仓仓根(调用方解析传入,
855
+ * probe8 :314-316 同款读法),跨仓仓根下的新测试文件可读、命中不再恒空(矩阵不因跨仓恒
856
+ * 预填 partial);根列表去重保序(主仓根优先命中),无跨仓注册的单仓调用行为零变化。
217
857
  * 坑 probe7-prefill-evidence(ql-20260915-004):anchors 逐词记录首命中 file:line(grep -n
218
858
  * 语义——首个包含该词的测试文件内的首行号),供证据列机械预填;terms/files 键维持旧形态
219
859
  * (既有消费方/测试零回归)。
860
+ * @param {Array<string>} testFiles 归属测试文件(仓根相对路径)
861
+ * @param {string} cwd 主仓 cwd
862
+ * @param {string|null} wtRoot worktree 根
863
+ * @param {string[]} [crossRoots] 跨仓仓根(local.yaml repos 注册根,绝对路径)
220
864
  * @returns {Record<number, {terms: string[], files: string[], anchors: Array<{term: string, file: string, line: number}>}>}
221
865
  * 普通对象,可 JSON 序列化
222
866
  */
223
- function buildAcceptanceHints(acceptanceItems, testFiles, cwd, wtRoot) {
867
+ function buildAcceptanceHints(acceptanceItems, testFiles, cwd, wtRoot, crossRoots = []) {
224
868
  const hints = {}
225
869
  if (!testFiles || testFiles.length === 0) return hints
226
870
  const cache = new Map()
@@ -228,8 +872,7 @@ function buildAcceptanceHints(acceptanceItems, testFiles, cwd, wtRoot) {
228
872
  if (cache.has(rel)) return cache.get(rel)
229
873
  let content = null
230
874
  const posix = String(rel).split('\\').join('/')
231
- for (const root of [cwd, wtRoot]) {
232
- if (!root) continue
875
+ for (const root of [...new Set([cwd, wtRoot, ...crossRoots].filter(Boolean))]) {
233
876
  try { content = readFileSync(join(root, posix), 'utf8'); break } catch { /* 换根重试 */ }
234
877
  }
235
878
  cache.set(rel, content)
@@ -270,7 +913,7 @@ function mdEscapeCell(text, cap = 200) {
270
913
  /**
271
914
  * 探针 7 判定/证据两列机械预填(坑 probe7-prefill-evidence,ql-20260915-004)。
272
915
  *
273
- * 背景:此前骨架判定列 `<待填:四选一>` + 证据列 `<TODO>` 全占位,24 格矩阵 agent 全量
916
+ * 背景:此前骨架判定列 `<待填:四选一>` + 证据列 `<TODO>` 全占位,24 格矩阵 agent 全量 probe1-noqa
274
917
  * 手填——CLI 已算出归属/关键词命中却弃之不用。预填规则(机械保守,agent 逐格复核改写):
275
918
  * - 无归属 → uncovered;acceptance 文本含 文档/部署/doc/deploy/manual/config 类词且无归属
276
919
  * → non-testable
@@ -310,7 +953,7 @@ function prefillMatrixCells(item, testFiles, hint) {
310
953
  /**
311
954
  * 渲染探针 7 段(骨架与幂等补段共用单一实现)。
312
955
  * 坑 probe7-prefill-evidence(ql-20260915-004):判定/证据两列由 prefillMatrixCells 机械预填
313
- * (原 `<待填:四选一>` / `<TODO>` 占位淘汰)——幂等保障沿用补段口径:段已在场(agent 已填/
956
+ * (原 `<待填:四选一>` / `<TODO>` 占位淘汰)——幂等保障沿用补段口径:段已在场(agent 已填/ probe1-noqa
314
957
  * 未填)一律不触碰(ensureAcceptanceMatrixSection 段在场即 no-op),预填只在骨架生成与缺段补齐
315
958
  * 两条新写路径生效,agent 已填内容永不被覆盖。
316
959
  * @param {{ applicable: boolean, tasks: Array<{task: string, acceptance: string[], testFiles: string[], hints: Record<number, {terms: string[], files: string[], anchors?: Array<{term: string, file: string, line: number}>}>}> }} p7
@@ -322,8 +965,13 @@ function renderProbe7Lines(p7) {
322
965
  L.push('- 不适用(无 TaskCard)')
323
966
  return L
324
967
  }
325
- L.push('<!-- 口径注记:探针 3 = 模块目录递归存在性面(allowed_paths 目录附近有没有测试);探针 7 = allowed_paths ∪ review changedFiles 结构归属承接面(每条 acceptance 由哪些测试承接);两者并排冲突以 7 为准。判定枚举(四选一):covered / partial / uncovered / non-testable(文档/部署类显式逃生门)。关键词命中只是提示,命中≠判定。 -->')
326
- L.push('<!-- 预填说明(ql-20260915-004):判定列为 CLI 机械预填,agent 逐格复核改写——规则:无归属→uncovered(文档/部署/doc/deploy/manual/config 类词→non-testable);有归属且命中≥1→covered;有归属零命中→partial。证据列给首命中 file:line 锚点或人工核验提示。预填≠结论:与事实不符的格子必须改写(枚举须保持 covered/partial/uncovered/non-testable 纯值,备注写在证据列)。 -->')
968
+ L.push('<!-- 口径注记:探针 3 = 模块目录递归存在性面(allowed_paths 目录附近有没有测试);探针 7 = allowed_paths ∪ review changedFiles ∪ 直接下游卡测试 结构归属承接面(每条 acceptance 由哪些测试承接;下游消费卡的测试可承接上游 provider 的 acceptance——probe7-provider-tests-in-consumer-card);两者并排冲突以 7 为准。判定枚举(四选一):covered / partial / uncovered / non-testable(文档/部署类显式逃生门)。关键词命中只是提示,命中≠判定。 -->')
969
+ L.push('<!-- 预填说明(ql-20260915-004):判定列为 CLI 机械预填,agent 逐格复核改写——规则:无归属→uncovered(文档/部署/doc/deploy/manual/config 类词→non-testable);有归属且命中≥1→covered;有归属零命中→partial。covered/partial 证据须含测试锚点三形态之一(file:line / `.test.` 测试文件名 / 反引号包裹的路径或测试名),行号可省;uncovered/non-testable 证据自由形态。预填≠结论:与事实不符的格子必须改写(枚举须保持 covered/partial/uncovered/non-testable 纯值,备注写在证据列)。 -->')
970
+ // 零自动化承接计数(坑 review-zero-coverage-unwalked,2026-09-16 EHS 二次复核实证:execute
971
+ // 期 review 走查面事实上跟着测试覆盖走——「新增-部门支线测试充分,编辑路径与相关方支线
972
+ // 零覆盖」恰是 5 个 P1 的藏身处。矩阵渲染时顺带计数零承接条目,verify 复核强制显式走查
973
+ // 这些路径并在「代码审查」节登记走查结论——把二次复核「提前兑现移交项」的打法固化)。
974
+ let zeroCoverage = 0
327
975
  for (const t of (p7.tasks || [])) {
328
976
  L.push('')
329
977
  L.push(`**${t.task}**`)
@@ -342,9 +990,14 @@ function renderProbe7Lines(p7) {
342
990
  ? `${h.terms.map(x => mdEscapeCell(x)).join('、')}(${(h.files || []).map(f => `\`${f}\``).join('、')})`
343
991
  : '—'
344
992
  const pre = prefillMatrixCells(item, t.testFiles, h)
993
+ if (pre.verdict === 'uncovered' || pre.verdict === 'partial') zeroCoverage++
345
994
  L.push(`| ${mdEscapeCell(item)} | ${attribCell} | ${hintCell} | ${pre.verdict} | ${pre.evidence} |`)
346
995
  })
347
996
  }
997
+ if (zeroCoverage > 0) {
998
+ L.push('')
999
+ L.push(`- ⚠️ 零/半自动化承接条目 ${zeroCoverage} 条——这些路径无测试兜底,verify 复核必须**显式走查**(尤其编辑/更新链路与非主分支流:二次复核实证它们正是 P1 藏身处),走查结论登记进「代码审查」节`)
1000
+ }
348
1001
  return L
349
1002
  }
350
1003
 
@@ -394,7 +1047,12 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
394
1047
  const lines = readFileSync(abs, 'utf8').split('\n')
395
1048
  lines.forEach((line, i) => {
396
1049
  if (probe1.matches.length >= PROBE1_MAX_MATCHES) return
397
- if (TODO_MARKER_RE.test(line)) probe1.matches.push({ file: probePath, line: i + 1, content: line.trim().slice(0, 160) })
1050
+ // probe1-noqa 行级豁免(坑 probe1-self-referential-noise,2026-09-16 EHS 变更 verify 实证:
1051
+ // 探针基础设施自身源码被扫描时 26 处命中全是匹配器定义/骨架模板字符串/修复注释引文——
1052
+ // 逐条人工裁定纯耗损。行内含 `probe1-noqa` 即跳过(注释行加 `// probe1-noqa`、
1053
+ // 字符串模板行在语句闭合后加 `/* probe1-noqa */` 不污染输出内容))。
1054
+ if (line.includes('probe1-noqa')) return
1055
+ if (isUnimplementedMarkerLine(line)) probe1.matches.push({ file: probePath, line: i + 1, content: line.trim().slice(0, 160) })
398
1056
  })
399
1057
  } catch {
400
1058
  probe1.skippedFiles.push(e.path)
@@ -409,8 +1067,23 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
409
1067
  let allowed = []
410
1068
  if (existsSync(cardPath)) allowed = parseAllowedPaths(readFileSync(cardPath, 'utf8'))
411
1069
  const moduleDirs = [...new Set(allowed.map(p => dirname(p.split('\\').join('/'))).filter(d => d && d !== '.'))]
412
- const testFiles = []
1070
+ // JVM 镜像测试根(坑 probe3-java-mirror-blind,2026-09-15 EHS 生产实证 task-02~06 五连
1071
+ // 假⚠️):Maven/Gradle 布局测试在 src/test/<lang> 同包镜像树,与 src/main 侧永不
1072
+ // co-located——只递归 allowed_paths 目录(全在 main 侧)必然落空。对命中 src/main/<lang>
1073
+ // 的目录补推镜像目录进扫描集(主仓或 worktree 任一侧存在才列/才扫——不存在的镜像根=
1074
+ // 该包真无测试,保持 ⚠️ 真信号)。
1075
+ const mirrorDirs = []
413
1076
  for (const d of moduleDirs) {
1077
+ const m = d.match(/^(.*\/src\/)main(\/(?:java|kotlin|scala|groovy)(?:\/.*)?)$/)
1078
+ if (!m) continue
1079
+ const mirror = `${m[1]}test${m[2]}`
1080
+ if (mirrorDirs.includes(mirror) || moduleDirs.includes(mirror)) continue
1081
+ if (existsSync(join(cwd, mirror)) || (wtRoot && wtRoot !== cwd && existsSync(join(wtRoot, mirror)))) {
1082
+ mirrorDirs.push(mirror)
1083
+ }
1084
+ }
1085
+ const testFiles = []
1086
+ for (const d of [...moduleDirs, ...mirrorDirs]) {
414
1087
  // 双根并集扫描(坑 probe3-worktree-test-false-negative,2026-09-10 驾驭小结第五批②,
415
1088
  // 用户实证 5 条假 warning):apply 前新测试文件只在 worktree(untracked),而模块目录在
416
1089
  // 主仓**已存在**——旧「主仓目录缺失才回退 worktree」条件不触发,探针 3 报「未找到测试
@@ -428,6 +1101,7 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
428
1101
  probe3.tasks.push({
429
1102
  task: taskId,
430
1103
  moduleDirs,
1104
+ mirrorDirs,
431
1105
  testFiles: testFiles.slice(0, 10),
432
1106
  testFileCount: testFiles.length,
433
1107
  hasTest: testFiles.length > 0,
@@ -438,6 +1112,19 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
438
1112
  // ── 探针 5:API 契约对账(复用 verifyApiParity:endpoints.json × 前端调用)──
439
1113
  const runtimeRoot = resolveRuntimeRoot({ specRoot: specDir }, specBase)
440
1114
  const probe5 = verifyApiParity(specBase, cwd, runtimeRoot, changeName)
1115
+ // 跨仓 task 卡计数(坑 probe5-cross-repo-scope-blindness,2026-09-15 复盘:跨仓仓的前端
1116
+ // 调用不在 parity 扫描根内,「frontend 0 调用」会被误读为漏配——渲染侧显式注记扫描面
1117
+ // 边界。计数失败按 0(不渲染注记,零新增失败面)。
1118
+ try {
1119
+ const tasksDir = join(changeDir, 'tasks')
1120
+ if (changeName && existsSync(tasksDir)) {
1121
+ let crossRepoCardCount = 0
1122
+ for (const f of readdirSync(tasksDir).filter(n => /^task-\d+\.md$/.test(n))) {
1123
+ if (parseRepo(readFileSync(join(tasksDir, f), 'utf8'))) crossRepoCardCount++
1124
+ }
1125
+ if (crossRepoCardCount > 0) probe5.crossRepoCardCount = crossRepoCardCount
1126
+ }
1127
+ } catch { /* tasks 目录不可读 → 不注记 */ }
441
1128
 
442
1129
  // ── 探针 6:代码删除对账(git diff --name-status HEAD 的 D/R × design 声明三态)──
443
1130
  const probe6 = { deletions: [], unavailable: false, note: '以 git 事实为准(真实 > 声明);是否 FAIL blocker 由 agent 诚实判定' }
@@ -494,23 +1181,84 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
494
1181
  }
495
1182
  } catch { /* tasks 目录不可读 → applicable 维持 false */ }
496
1183
  probe7.applicable = cards.length > 0
1184
+ // 跨仓仓根解析(task-05 / FR-09):测试文件内容读取双根扩多根——跨仓卡(repo: <key>)
1185
+ // 的 allowed_paths/review changedFiles 相对其仓根,cwd/wtRoot 双根读不到 → 关键词命中
1186
+ // 恒空、矩阵恒预填 partial。读法同探针 8 先例:join(specBase, 'local.yaml') +
1187
+ // parseRepoRegistry,相对路径按 cwd resolve;注册表不可读 fail-open 空表(单仓双根
1188
+ // 行为零变化)。
1189
+ const probe7CrossRoots = []
1190
+ try {
1191
+ const localYamlPath = join(specBase, 'local.yaml')
1192
+ if (existsSync(localYamlPath)) {
1193
+ const registry = parseRepoRegistry(readFileSync(localYamlPath, 'utf8'))
1194
+ if (registry) {
1195
+ for (const raw of registry.values()) {
1196
+ if (!raw) continue
1197
+ const root = isAbsolute(raw) ? raw : resolve(cwd, raw)
1198
+ if (root && existsSync(root) && !probe7CrossRoots.includes(root)) probe7CrossRoots.push(root)
1199
+ }
1200
+ }
1201
+ }
1202
+ } catch { /* 注册表不可读 → 无跨仓根(fail-open) */ }
1203
+ // 跨卡归属(坑 probe7-provider-tests-in-consumer-card,2026-09-16 E 变更 verify 实证:8 格
1204
+ // 被机械预填 uncovered——task-03(测试卡)的用例测的是 task-01(provider)的导出函数,但归属
1205
+ // 只看本卡 allowed_paths ∪ review changedFiles,provider 卡的 acceptance 永远连不上消费卡的
1206
+ // 测试。修法:归属集扩为「本卡 ∪ 直接依赖本卡的卡」的测试文件——M depends_on N 则 M 的测试
1207
+ // 可承接 N 的 acceptance(下游消费卡天然测上游产物)。只扩直接依赖(v1,传递闭包易把无关
1208
+ // 测试卷进来放大 partial 噪音)。
1209
+ const dependsOnOf = new Map()
1210
+ for (const c of cards) {
1211
+ const dm = String(c.raw).match(/^depends_on:\s*\[?([^\]\n]*)\]?/m)
1212
+ const deps = (dm ? dm[1] : '').split(/[,\s]+/).map(s => s.trim()).filter(s => /^task-\d+$/.test(s))
1213
+ dependsOnOf.set(c.task, deps)
1214
+ }
497
1215
  for (const card of cards) {
498
1216
  const fromAllowed = parseAllowedPaths(card.raw)
499
1217
  .map(p => String(p).replace(/^NEW:\s*/, '').trim())
500
1218
  .filter(p => p && isProbe7TestPath(p))
501
1219
  const fromReview = runId ? readReviewChangedTestFiles(runtimeRoot, runId, card.task) : []
502
- const testFiles = [...new Set([...fromAllowed, ...fromReview])]
1220
+ // 直接下游卡的归属测试并入(provider acceptance 由消费卡测试承接)
1221
+ const fromDependents = []
1222
+ for (const other of cards) {
1223
+ if (other.task === card.task) continue
1224
+ const deps = dependsOnOf.get(other.task) || []
1225
+ if (!deps.includes(card.task)) continue
1226
+ for (const p of parseAllowedPaths(other.raw)) {
1227
+ const t = String(p).replace(/^NEW:\s*/, '').trim()
1228
+ if (t && isProbe7TestPath(t) && !fromDependents.includes(t)) fromDependents.push(t)
1229
+ }
1230
+ }
1231
+ const testFiles = [...new Set([...fromAllowed, ...fromReview, ...fromDependents])]
503
1232
  probe7.tasks.push({
504
1233
  task: card.task,
505
1234
  acceptance: card.acceptance,
506
1235
  testFiles,
507
- hints: buildAcceptanceHints(card.acceptance, testFiles, cwd, wtRoot),
1236
+ hints: buildAcceptanceHints(card.acceptance, testFiles, cwd, wtRoot, probe7CrossRoots),
508
1237
  })
509
1238
  }
510
1239
  }
511
1240
  }
512
1241
 
513
- return { probe1, probe3, probe5, probe6, probe7 }
1242
+ // ── 探针 8:载荷字段契约对账(advisory;2026-09-16 EHS 二次复核驱动——字段错位族 4 处
1243
+ // P1 是 URL 级 parity 的真空带)。fail-soft:内部全兜,异常降级 not-applicable 不炸整体。──
1244
+ let probe8 = { applicable: false, mispairs: [], feOnly: [], missingNotNull: [], notes: [] }
1245
+ try {
1246
+ probe8 = runProbe8PayloadParity({ specBase, cwd, wtRoot, changeName })
1247
+ } catch (e) {
1248
+ probe8.notes = [`探针 8 执行失败(fail-soft 跳过):${e && e.message ? e.message : e}`]
1249
+ }
1250
+
1251
+ // ── 探针 9:守卫一致性(advisory;2026-09-15 EHS doSubmit 越权 P1 驱动——同实体 CRUD
1252
+ // 守卫不一致是编码式权限典型漏洞形态,探针 5/8 均零覆盖)。fail-soft 同探针 8:异常降级
1253
+ // not-applicable 注记不炸整体。──
1254
+ let probe9 = { applicable: false, javaFileCount: 0, groupCount: 0, inconsistentGroups: [], notes: [] }
1255
+ try {
1256
+ probe9 = runProbe9GuardConsistency({ specBase, cwd, wtRoot, changeName })
1257
+ } catch (e) {
1258
+ probe9.notes = [`探针 9 执行失败(fail-soft 跳过):${e && e.message ? e.message : e}`]
1259
+ }
1260
+
1261
+ return { probe1, probe3, probe5, probe6, probe7, probe8, probe9 }
514
1262
  }
515
1263
 
516
1264
  /**
@@ -522,7 +1270,7 @@ export function renderVerifyProbesReport(result) {
522
1270
 
523
1271
  L.push('#### 探针 1:未实现标记扫描(design 清单文件)')
524
1272
  if (probe1.matches.length === 0) {
525
- L.push('- ✅ 无 TODO/FIXME/尚未实现 标记命中')
1273
+ L.push('- ✅ 无 TODO/FIXME/尚未实现 标记命中') /* probe1-noqa */
526
1274
  } else {
527
1275
  for (const m of probe1.matches) L.push(`- ⚠️ \`${m.file}:${m.line}\` ${m.content}`)
528
1276
  }
@@ -532,7 +1280,7 @@ export function renderVerifyProbesReport(result) {
532
1280
  L.push('')
533
1281
 
534
1282
  L.push('#### 探针 2:设计关键词覆盖')
535
- L.push('<!--TODO: 半语义探针——从 design 提取能力关键词逐个 grep 确认实现(agent 执行)-->')
1283
+ L.push('<!--TODO: 半语义探针——从 design 提取能力关键词逐个 grep 确认实现(agent 执行)-->') /* probe1-noqa */
536
1284
  L.push('')
537
1285
 
538
1286
  L.push('#### 探针 3:验收标准测试覆盖')
@@ -543,9 +1291,13 @@ export function renderVerifyProbesReport(result) {
543
1291
  if (!t.located) {
544
1292
  L.push(`- ⚠️ ${t.task}: 无 task 卡/allowed_paths,无法定位模块目录(agent 手查)`)
545
1293
  } else if (t.hasTest) {
546
- L.push(`- ✅ ${t.task}: 模块目录(${t.moduleDirs.join('、')})找到 ${t.testFileCount} 个测试文件(${t.testFiles.slice(0, 5).join('、')}${t.testFileCount > 5 ? ' …' : ''})`)
1294
+ const mirrorNote = (t.mirrorDirs && t.mirrorDirs.length > 0)
1295
+ ? `;JVM 镜像测试根(${t.mirrorDirs.join('、')})` : ''
1296
+ L.push(`- ✅ ${t.task}: 模块目录(${t.moduleDirs.join('、')})${mirrorNote}找到 ${t.testFileCount} 个测试文件(${t.testFiles.slice(0, 5).join('、')}${t.testFileCount > 5 ? ' …' : ''})`)
547
1297
  } else {
548
- L.push(`- ⚠️ ${t.task}: 模块目录(${t.moduleDirs.join('、')})递归未找到测试文件(含 co-located tests/)`)
1298
+ const mirrorNote = (t.mirrorDirs && t.mirrorDirs.length > 0)
1299
+ ? '(含 co-located tests/ 与 JVM src/test 镜像根)' : '(含 co-located tests/)'
1300
+ L.push(`- ⚠️ ${t.task}: 模块目录(${t.moduleDirs.join('、')})递归未找到测试文件${mirrorNote}`)
549
1301
  }
550
1302
  }
551
1303
  }
@@ -559,11 +1311,14 @@ export function renderVerifyProbesReport(result) {
559
1311
  L.push('')
560
1312
 
561
1313
  L.push('#### 探针 4:决策追踪覆盖')
562
- L.push('<!--TODO: 语义探针——D-xxx@vN → FR-xxx → plan/task 引用 → 证据回指闭环(agent 执行)-->')
1314
+ L.push('<!--TODO: 语义探针——D-xxx@vN → FR-xxx → plan/task 引用 → 证据回指闭环(agent 执行)-->') /* probe1-noqa */
563
1315
  L.push('')
564
1316
 
565
1317
  L.push('#### 探针 5:API Contract Parity')
566
1318
  L.push(`- ${probe5.summary || `backend ${probe5.backendCount ?? 0} 端点 / frontend ${probe5.frontendCount ?? 0} 调用`}`)
1319
+ if (probe5.crossRepoCardCount > 0) {
1320
+ L.push(`- ℹ️ parity 扫描面只含主仓——另有 ${probe5.crossRepoCardCount} 张跨仓 task 卡的仓不在扫描根内,跨仓前端调用/端点请到对应仓核对(D-004 跨仓对账不在本变更范围)`)
1321
+ }
567
1322
  if ((probe5.scanRoots || []).length > 1) {
568
1323
  L.push(`- ℹ️ 后端端点比对集为多根并集(主仓既有 ∪ worktree 新增 ∪ 存量 artifact),共扫 ${probe5.scanRoots.length} 个根`)
569
1324
  }
@@ -581,7 +1336,16 @@ export function renderVerifyProbesReport(result) {
581
1336
  L.push('- ❌ contract gap 是真实集成缺陷——诚实判 FAIL 并回 execute 补端点(CLI 仅 advisory 不硬阻断)')
582
1337
  }
583
1338
  if ((probe5.unusedBackend || []).length > 0) {
584
- L.push(`- ⚠️ ${probe5.unusedBackend.length} 个后端端点前端未调用(warning 不阻断):${probe5.unusedBackend.slice(0, 5).map(u => `${u.method} ${u.path}`).join('、')}${probe5.unusedBackend.length > 5 ? ' …' : ''}`)
1339
+ // unused 分层展示(probe5-unused-stock-noise):本变更相关(artifact 端点集内)逐条列出,
1340
+ // 存量其余折叠计数——EHS 实证 490 个存量端点刷屏淹没了真信号。旧字段形态(无分层)零回归全列。
1341
+ const relevant = Array.isArray(probe5.unusedChangeRelevant) && probe5.unusedChangeRelevant.length >= 0
1342
+ ? probe5.unusedChangeRelevant : probe5.unusedBackend
1343
+ const stock = typeof probe5.unusedStockCount === 'number' ? probe5.unusedStockCount : 0
1344
+ L.push(`- ⚠️ ${relevant.length} 个本变更端点前端未调用(warning 不阻断):${relevant.slice(0, 5).map(u => `${u.method} ${u.path}`).join('、')}${relevant.length > 5 ? ' …' : ''}`)
1345
+ if (stock > 0) L.push(`- ℹ️ 另有 ${stock} 个存量端点未调用(他模块存量噪音,已折叠不逐条列出)`)
1346
+ }
1347
+ if ((probe5.crossRepoNotes || []).length > 0) {
1348
+ for (const n of probe5.crossRepoNotes.slice(0, 5)) L.push(`- ℹ️ ${n}`)
585
1349
  }
586
1350
  L.push('')
587
1351
 
@@ -596,9 +1360,88 @@ export function renderVerifyProbesReport(result) {
596
1360
  }
597
1361
  }
598
1362
  L.push(`- ℹ️ ${probe6.note}`)
1363
+ L.push('')
1364
+ L.push(...(renderProbe8Lines(result.probe8 || { applicable: false, mispairs: [], feOnly: [], missingNotNull: [], notes: [] })))
1365
+ // 探针 9 紧随探针 8;旧 result 无 probe9 键(存量调用方/合成 result)→ applicable=false
1366
+ // 渲染「不适用」行 + notes,零回归(探针 7 兜底口径同款)。
1367
+ L.push(...(renderProbe9Lines(result.probe9 || { applicable: false, javaFileCount: 0, groupCount: 0, inconsistentGroups: [], notes: [] })))
599
1368
  return L.join('\n')
600
1369
  }
601
1370
 
1371
+ /**
1372
+ * 渲染探针 8 段(advisory 口径注记随段输出——命中≠结论,agent 逐条复核)。
1373
+ * @param {{applicable: boolean, backendFieldCount?: number, feKeyCount?: number, notNullCount?: number,
1374
+ * javaFileCount?: number, sqlFileCount?: number, feFileCount?: number,
1375
+ * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
1376
+ * contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}>}} p8
1377
+ * @returns {string[]} 行数组(含段标题)
1378
+ */
1379
+ function renderProbe8Lines(p8) {
1380
+ const L = [PROBE8_HEADING]
1381
+ if (!p8 || !p8.applicable) {
1382
+ L.push('- 不适用(清单无 Java/SQL 后端面,或 design.md 缺失)')
1383
+ for (const n of (p8 && p8.notes) || []) L.push(`- ℹ️ ${n}`)
1384
+ return L
1385
+ }
1386
+ L.push('<!-- 口径注记:静态启发式对账——前端请求载荷键(apiFetch/request/axios/fetch 调用邻近对象键)× 后端 Java private 字段(归一化覆盖)∪ 疑似错位配对 ∪ SQL NOT NULL 列缺送。命中≠结论:UI 本地态键/服务端填充列会自然出现在差异里,agent 逐条复核(2026-09-16 EHS 二次复核实证:leaderUserId↔rpLeaderUserId 字段错位致相关方支线三端不可用、缺发 reportOrgId 致小程序开立被拒——URL 级 parity 抓不住载荷级错位)。 -->')
1387
+ L.push(`- ℹ️ 比对面:前端载荷键 ${p8.feKeyCount ?? 0}(${p8.feFileCount ?? 0} 前端文件)× 后端字段 ${p8.backendFieldCount ?? 0}(${p8.javaFileCount ?? 0} Java 文件)+ NOT NULL 列 ${p8.notNullCount ?? 0}(${p8.sqlFileCount ?? 0} SQL 文件)`)
1388
+ if ((p8.mispairs || []).length > 0) {
1389
+ L.push(`- ⚠️ 疑似字段错位配对(前后端名近形,人工核实一对一映射):${p8.mispairs.slice(0, 8).map(p => `${p.fe} ↔ ${p.be}`).join('、')}${p8.mispairs.length > 8 ? ` …共 ${p8.mispairs.length} 对` : ''}`)
1390
+ }
1391
+ if ((p8.feOnly || []).length > 0) {
1392
+ L.push(`- ℹ️ 前端独有键(候选 UI 本地态/字段错位/跨层无关):${p8.feOnly.slice(0, 10).join('、')}${p8.feOnly.length > 10 ? ` …共 ${p8.feOnly.length} 个` : ''}`)
1393
+ }
1394
+ if ((p8.missingNotNull || []).length > 0) {
1395
+ L.push(`- ⚠️ NOT NULL 列前端未见(候选必填缺送/服务端填充):${p8.missingNotNull.slice(0, 8).map(c => c.col).join('、')}${p8.missingNotNull.length > 8 ? ` …共 ${p8.missingNotNull.length} 个` : ''}`)
1396
+ }
1397
+ // 契约维度两行(task-01,advisory):契约外载荷键(design 契约面未见)/ 必填漏发(契约
1398
+ // required 前端载荷未见)。无契约面时 notes 已有 skipped 注记(下方循环渲染),不空段。
1399
+ if ((p8.contractOrphans || []).length > 0) {
1400
+ L.push(`- ⚠️ 契约外载荷键 ${p8.contractOrphans.length} 条(design 契约面未见,候选契约滞后/跨层私加):${p8.contractOrphans.slice(0, 8).map(o => o.hint ? `${o.fe}(疑似对应 ${o.hint})` : o.fe).join('、')}${p8.contractOrphans.length > 8 ? ` …共 ${p8.contractOrphans.length} 条` : ''}`)
1401
+ }
1402
+ if ((p8.missingRequired || []).length > 0) {
1403
+ L.push(`- ⚠️ 契约必填漏发 ${p8.missingRequired.length} 条(契约 required 前端载荷未见,人工核实提交链路):${p8.missingRequired.slice(0, 8).map(r => `${r.field}←${r.contract}`).join('、')}${p8.missingRequired.length > 8 ? ` …共 ${p8.missingRequired.length} 条` : ''}`)
1404
+ }
1405
+ if ((p8.mispairs || []).length === 0 && (p8.missingNotNull || []).length === 0
1406
+ && (p8.contractOrphans || []).length === 0 && (p8.missingRequired || []).length === 0) {
1407
+ L.push('- ✅ 载荷字段面零疑似差异(归一化覆盖 + NOT NULL 全见)')
1408
+ }
1409
+ for (const n of p8.notes || []) L.push(`- ℹ️ ${n}`)
1410
+ return L
1411
+ }
1412
+
1413
+ /**
1414
+ * 渲染探针 9 段(renderProbe8Lines 同构;advisory 口径注记随段输出——存在性检查非语义审计,
1415
+ * 聚类启发式由 agent 裁定)。汇总行「守卫不一致实体组 N 个」的 N 供 verify-postcheck 一致性
1416
+ * 抽查锚点(对齐探针 8 先例,本卡只出 producer 侧)。
1417
+ * @param {{applicable: boolean, javaFileCount?: number, groupCount?: number,
1418
+ * inconsistentGroups: Array<{entity: string, guarded: string[], unguarded: string[], signals: Record<string, string[]>}>,
1419
+ * notes: string[]}} p9
1420
+ * @returns {string[]} 行数组(含段标题)
1421
+ */
1422
+ function renderProbe9Lines(p9) {
1423
+ const L = [PROBE9_HEADING]
1424
+ if (!p9 || !p9.applicable) {
1425
+ L.push('- 不适用(清单无 .java 改动文件,或 design.md 缺失)')
1426
+ for (const n of (p9 && p9.notes) || []) L.push(`- ℹ️ ${n}`)
1427
+ return L
1428
+ }
1429
+ L.push('<!-- 口径注记:存在性检查非语义审计——只查「守卫调用/注解模式存在」不查校验逻辑对错;聚类启发式由 agent 裁定(方法名前缀动词+尾段实体名词聚类,同文件同实体 ≥2 变更方法才比对,误组/漏组都可能——⚠️ 是定向复核提示不是结论)。 -->')
1430
+ L.push(`- ℹ️ 比对面:${p9.javaFileCount ?? 0} Java 文件 × 同实体变更方法组 ${p9.groupCount ?? 0}(组内 ≥2 方法才比对)`)
1431
+ if ((p9.inconsistentGroups || []).length > 0) {
1432
+ L.push(`- ⚠️ 守卫不一致实体组 ${p9.inconsistentGroups.length} 个(同实体有守卫/无守卫并存——越权风险面,2026-09-15 EHS doSubmit 越权同族,agent 逐组裁定)`)
1433
+ for (const g of p9.inconsistentGroups) {
1434
+ const guardedPart = (g.guarded || []).map(m => `${m}(${((g.signals && g.signals[m]) || []).join('、') || '?'})`).join('、')
1435
+ const unguardedPart = (g.unguarded || []).join('、')
1436
+ L.push(`- ⚠️ 实体 ${g.entity}:有守卫 [${guardedPart}] / 无守卫 [${unguardedPart}]`)
1437
+ }
1438
+ } else {
1439
+ L.push('- ✅ 同实体守卫信号一致(无有守卫/无守卫并存的实体组)')
1440
+ }
1441
+ for (const n of p9.notes || []) L.push(`- ℹ️ ${n}`)
1442
+ return L
1443
+ }
1444
+
602
1445
  /**
603
1446
  * 从 runVerifyProbes 结果构造 verify-facts.json 机器底稿对象(P3b 可复跑审计底稿)。
604
1447
  * 命令行统一 `sillyspec verify-probes --change <name>`;指标 fail-soft——字段不可得时少列
@@ -617,6 +1460,8 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
617
1460
  const p3 = (result && result.probe3) || {}
618
1461
  const p5 = (result && result.probe5) || {}
619
1462
  const p6 = (result && result.probe6) || {}
1463
+ const p8 = (result && result.probe8) || {}
1464
+ const p9 = (result && result.probe9) || {}
620
1465
  // v2(2026-09-08-ir-verify-facts):probes 机器段原样;conclusion/tests/requiredEvidence/
621
1466
  // runtimeEvidence/factsConsistency 五段是 slot-backfill/实测回填段(D-001@v2),--init 快照
622
1467
  // 不落键(writeVerifyFacts 分段合并时保留既有固化段),由 backfillFactsFromMdAndTests 填。
@@ -655,6 +1500,27 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
655
1500
  unavailable: typeof p6.unavailable === 'boolean' ? p6.unavailable : undefined,
656
1501
  }),
657
1502
  },
1503
+ probe8: {
1504
+ command,
1505
+ metrics: defined({
1506
+ mispairs: len(p8.mispairs),
1507
+ feOnly: len(p8.feOnly),
1508
+ missingNotNull: len(p8.missingNotNull),
1509
+ contractCount: num(p8.contractCount),
1510
+ contractOrphans: len(p8.contractOrphans),
1511
+ missingRequired: len(p8.missingRequired),
1512
+ feKeys: num(p8.feKeyCount),
1513
+ backendFields: num(p8.backendFieldCount),
1514
+ }),
1515
+ },
1516
+ probe9: {
1517
+ command,
1518
+ metrics: defined({
1519
+ javaFileCount: num(p9.javaFileCount),
1520
+ groupCount: num(p9.groupCount),
1521
+ inconsistentGroups: len(p9.inconsistentGroups),
1522
+ }),
1523
+ },
658
1524
  },
659
1525
  }
660
1526
  }
@@ -717,6 +1583,200 @@ export function writeVerifyFacts(changeDir, result, changeName, opts = {}) {
717
1583
  * @param {{ verifyMd: string, testCheckResult?: object|null }} opts
718
1584
  * @returns {{ facts: object, conclusion: string|null, evidenceCount: number, receiptCount: number, testsBackfilled: boolean }}
719
1585
  */
1586
+ // 移交项结构化(坑 handover-only-in-prose,2026-09-15/16 EHS 生产实证):verify 结论
1587
+ // PASS WITH NOTES 的移交条目(环境阻断复跑/人工验收/待执行脚本)此前只活在结论槽正文
1588
+ // 叙述——后续独立复核发现被环境阻断 deferred 的集成测试里正藏着 5 个 P1;「移交项没有
1589
+ // 结构化清单 = 没人兜」。骨架新增「## 移交项(结构化)」章节(三列表格 + 类型枚举注释),
1590
+ // 本函数解析其表行为机器可读 items(facts.handover 回填 + advisory 判定用)。
1591
+ // 容错:占位行(<待填…)跳过;类型归一小写连字符(ENV-BLOCKED→env-blocked);未知类型
1592
+ // 保留原值(advisory 面向 agent 复核,不静默丢弃);非表格行(prose/注释)忽略。
1593
+ const HANDOVER_HEADING_RE = /^## 移交项(结构化)[^\n]*$/m
1594
+ // severity 第 4 列(2026-09-17-pass-cap-semantics task-01 / D-005@v2 / FR-06):三列正则扩
1595
+ // 四列,存量三列表格行零迁移兼容(四列/三列正则互斥命中——四列行 5 管道段、三列行 4 管道段,
1596
+ // [^|] 组不含管道无回溯交叠)。缺省按类型映射:db-script/env-blocked→blocking(X-07:集成
1597
+ // 复跑与 fix.sql 恒 blocking,分层不削弱封顶);manual-acceptance/other→advisory;未知类型
1598
+ // 保守取 blocking(fail-closed 侧——severity 低标会漏封顶,宁可多拦;未知类型本就该 agent
1599
+ // 复核,advisory 面向复核但封顶语义优先)。
1600
+ const HANDOVER_ROW4_RE = /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*([^|]*?)\s*\|\s*$/
1601
+ const HANDOVER_ROW3_RE = /^\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*([^|]*?)\s*\|\s*$/
1602
+ const HANDOVER_DEFAULT_SEVERITY = (type) =>
1603
+ (type === 'manual-acceptance' || type === 'other') ? 'advisory' : 'blocking'
1604
+ // 降级理由文法(X-02):(降级:<理由>,依据 <file:line 或 D-xxx>)——全角括号/冒号/逗号,
1605
+ // 依据锚点 D-xxx 或 file:line。JS 正则不转义全角括号(知识库坑)。blocking→advisory 降级
1606
+ // 未命中本文法 → 仍按 blocking(fail-closed,防「全标 advisory」钻空——D-005@v2)。
1607
+ const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+|[^\s,):]+:\d+))/
1608
+ // severity 解析:显式 blocking 优先(混写保守);显式 advisory 对 blocking 缺省类型构成降级,
1609
+ // 理由文法在第 4 列或条件列任一命中即认(骨架第 4 列定型前 producer 宽收两侧;抽查面归
1610
+ // checkProbeConsistency 消费侧后续任务);两枚举都不含 → 缺省映射。
1611
+ function resolveHandoverSeverity(type, severityCol, condition) {
1612
+ const col = String(severityCol || '')
1613
+ if (/blocking/i.test(col)) return 'blocking'
1614
+ if (/advisory/i.test(col)) {
1615
+ if (HANDOVER_DEFAULT_SEVERITY(type) !== 'blocking') return 'advisory'
1616
+ return (HANDOVER_DOWNGRADE_REASON_RE.test(col) || HANDOVER_DOWNGRADE_REASON_RE.test(String(condition || '')))
1617
+ ? 'advisory' : 'blocking'
1618
+ }
1619
+ return HANDOVER_DEFAULT_SEVERITY(type)
1620
+ }
1621
+ export function parseHandoverRows(md) {
1622
+ const text = String(md || '').replace(/\r\n/g, '\n')
1623
+ const m = text.match(HANDOVER_HEADING_RE)
1624
+ if (!m) return []
1625
+ const after = text.slice(m.index + m[0].length)
1626
+ const nextSection = after.match(/\n## /)
1627
+ const section = nextSection ? after.slice(0, nextSection.index) : after
1628
+ const items = []
1629
+ for (const line of section.split('\n')) {
1630
+ const row = line.match(HANDOVER_ROW4_RE) || line.match(HANDOVER_ROW3_RE)
1631
+ if (!row) continue
1632
+ const type = row[1].trim()
1633
+ const item = row[2].trim()
1634
+ const condition = row[3].trim()
1635
+ if (type === '类型' || /^-{2,}$/.test(type.replace(/\|/g, ''))) continue // 表头/分隔行
1636
+ if (type.startsWith('<') || item.startsWith('<')) continue // 骨架占位行
1637
+ if (!type || !item) continue
1638
+ const normalizedType = type.toLowerCase().replace(/[\s_]+/g, '-')
1639
+ items.push({
1640
+ type: normalizedType,
1641
+ item,
1642
+ condition,
1643
+ severity: resolveHandoverSeverity(normalizedType, row[4], condition),
1644
+ })
1645
+ }
1646
+ return items
1647
+ }
1648
+
1649
+ // ── stage-contract 动态绑定(D-011 producer 侧取数;分层单向——全局硬约束 3)──
1650
+ // extractAcceptanceMatrixSlots 经顶层 await 动态 import 绑定(非静态 import-from 语句,
1651
+ // 不造 import 环:stage-contract 静态闭包不含 verify-probes,加载序任一方向均无环)。
1652
+ // conclusion 先例是调用侧传参形态;此处选零 gates 改动形态——首次(gates.js 收尾前置)与
1653
+ // 二次(testCheckResult 后)两调用点自动覆盖,主路径无条件产出(X-08)不依赖调用方传参。
1654
+ // 加载失败降级 null:matrixPartialRows 计 0 + fail-soft 注记(additive 字段缺省不炸)。
1655
+ let extractAcceptanceMatrixSlotsFn = null
1656
+ try {
1657
+ ;({ extractAcceptanceMatrixSlots: extractAcceptanceMatrixSlotsFn } = await import('./stage-contract.js'))
1658
+ } catch { extractAcceptanceMatrixSlotsFn = null }
1659
+
1660
+ /**
1661
+ * db/<file>.sql 执行声明解析(X-03 文法,2026-09-17-pass-cap-semantics task-01 / D-007 声明面):
1662
+ * 两类来源——①回执槽(## 集成验证回执)条目 command 含 db/<file>.sql(复用 parseEvidenceSlots
1663
+ * 现成解析,不自造第二套);②声明行「已对目标库执行:db/<file>.sql」(全/半角冒号均认)。
1664
+ * @param {string} md verify-result.md 全文
1665
+ * @returns {string[]} 去重排序后的 db/<file>.sql 路径(posix 形态,与 apply 文件集直接可比)
1666
+ */
1667
+ const DB_SCRIPT_REF_RE = /(?:^|[^\w/])(db\/[\w./-]+\.sql)/g
1668
+ const DB_SCRIPT_DECLARE_RE = /已对目标库执行[::]\s*`?(db\/[\w./-]+\.sql)/g
1669
+ export function parseDbScriptDeclarations(md) {
1670
+ const text = String(md || '').replace(/\r\n/g, '\n')
1671
+ const found = new Set()
1672
+ const slots = parseEvidenceSlots(text)
1673
+ for (const receipt of slots.runtimeEvidence || []) {
1674
+ for (const m of String((receipt && receipt.command) || '').matchAll(DB_SCRIPT_REF_RE)) found.add(m[1])
1675
+ }
1676
+ for (const m of text.matchAll(DB_SCRIPT_DECLARE_RE)) found.add(m[1])
1677
+ return [...found].sort()
1678
+ }
1679
+
1680
+ // 回执 command 来源分类(X-10 / D-006:只认命令来源声明、不解析日志内容;枚举
1681
+ // cross-layer|build|unit,未定类默认 build——fail-closed 侧:build 不构成「集成实测已跑」,
1682
+ // 宁可触发封顶要求 handover)。对齐目标:change-risk-profile.js classifyReceiptSourceTag
1683
+ // (RECEIPT_SOURCE_CROSS_LAYER_RE/RECEIPT_SOURCE_UNIT_RE)——两侧正则族逐词保持同步演进
1684
+ // (打标单点归 change-risk-profile 侧,本函数是 producer 判定侧;G-3 收口:producer 侧
1685
+ // 曾漏 smoke 一词致 facts.integrationRan 偏 not-ran 误触封顶,已对齐,后续任一侧增词必须
1686
+ // 双侧同步)。cross-layer=起服务/HTTP/进程对进程;smoke 冒烟命令族计 cross-layer(D-006
1687
+ // 退役判据:批次 C commands.smoke 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟
1688
+ // 即跨层实测,不得默认 build 误拦金路径);unit=单测 runner 直跑(JUnitCore/node --test/
1689
+ // mocha/jest/vitest/pytest 等);其余(compile/lint/构建/mvn test 等混合形态)一律 build。
1690
+ const RECEIPT_CROSS_LAYER_RE = new RegExp([
1691
+ '\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
1692
+ '\\biwr\\b', '\\birm\\b', 'https?://',
1693
+ 'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
1694
+ '\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|smoke)\\b',
1695
+ '\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
1696
+ '\\b(?:nc|netcat|telnet|socat)\\b',
1697
+ ].join('|'), 'i')
1698
+ const RECEIPT_UNIT_RE = /\bJUnitCore\b|\bnode\s+--test\b|\bmocha\b|\bjest\b|\bvitest\b|\bpytest\b|\bphpunit\b|\b(?:npm|pnpm|yarn|bun)\s+(?:run\s+)?test\b/i
1699
+ function classifyReceiptCommandSource(command) {
1700
+ const cmd = String(command || '')
1701
+ if (RECEIPT_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
1702
+ if (RECEIPT_UNIT_RE.test(cmd)) return 'unit'
1703
+ return 'build'
1704
+ }
1705
+
1706
+ /**
1707
+ * facts.integrationRan 判定(D-006 判定表,2026-09-17-pass-cap-semantics task-01):
1708
+ * 已跑 = ①quality-scan 实测记录在场(路径规则同 run/verify-quality-scan.js:36
1709
+ * qualityScanRecordPath:specBase/.runtime/verify-quality-scan-<changeName>.json,specBase/
1710
+ * changeName 自 factsPath 目录上推,不依赖调用方传参)且 commands.test 实跑(status 非
1711
+ * skipped)且 test_strategy ∈ {full, module, evidence-auto}(evidence-auto 降级 module 已跑
1712
+ * 子集按 module 档算已跑;local.yaml 未配置默认 full);或 ②回执槽存在命令来源含跨层调用的
1713
+ * 条目。未跑 = test_strategy=skip / 无记录 / 回执仅 compile·lint·纯单测来源。记录缺失
1714
+ * (X-01:--done 亲测替代扫描场景时序不可得)→ not-ran + fail-open 注记(不阻断)。
1715
+ * @param {string} factsPath verify-facts.json 路径(<specBase>/changes/<name>/verify-facts.json)
1716
+ * @param {Array<{command: string}>} runtimeEvidence 回执槽解析结果(parseEvidenceSlots 产出)
1717
+ * @returns {{ ran: 'ran'|'not-ran', notes: string[] }}
1718
+ */
1719
+ function judgeIntegrationRan(factsPath, runtimeEvidence) {
1720
+ const changeDir = dirname(factsPath)
1721
+ const changeName = basename(changeDir)
1722
+ const specBase = dirname(dirname(changeDir))
1723
+ let record = null
1724
+ try {
1725
+ const rec = JSON.parse(readFileSync(join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`), 'utf8'))
1726
+ if (rec && rec.schemaVersion === 1 && rec.source === 'cli-noai') record = rec
1727
+ } catch { record = null }
1728
+ let strategy = 'full' // 未配置 test_strategy 默认全量(verify-postcheck resolveTestStrategy 同口径)
1729
+ try {
1730
+ const yamlText = readFileSync(join(specBase, 'local.yaml'), 'utf8').replace(/\r\n?/g, '\n')
1731
+ const sm = yamlText.match(/^\s*test_strategy:\s*([A-Za-z_-]+)\s*(?:#.*)?$/m)
1732
+ if (sm) strategy = sm[1]
1733
+ } catch { /* local.yaml 不可读 → 按 full 缺省 */ }
1734
+ const scanRan = Boolean(record && record.testResult && record.testResult.status && record.testResult.status !== 'skipped')
1735
+ if (scanRan && ['full', 'module', 'evidence-auto'].includes(strategy)) return { ran: 'ran', notes: [] }
1736
+ const receipts = Array.isArray(runtimeEvidence) ? runtimeEvidence : []
1737
+ if (receipts.some(r => classifyReceiptCommandSource(r && r.command) === 'cross-layer')) {
1738
+ return { ran: 'ran', notes: [] }
1739
+ }
1740
+ const notes = []
1741
+ if (!record) {
1742
+ notes.push(`ℹ️ quality-scan 实测记录缺失(${join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`)})——facts.integrationRan 按 not-ran 判定(X-01 fail-open 注记,不阻断;--done 亲测替代扫描的场景记录时序不可得,出路=重跑质量扫描步或降级 NOTES)`)
1743
+ }
1744
+ return { ran: 'not-ran', notes }
1745
+ }
1746
+
1747
+ /**
1748
+ * Runtime Evidence「不涉及」行识别(X-18 文法,2026-09-17-pass-cap-semantics task-01 / D-004):
1749
+ * 「## Runtime Evidence」节内匹配「行含 端点|请求-响应|服务端点 关键词且以 不涉及 收尾」的
1750
+ * 表格行——收尾判定剥行尾管道(含全角|)后 endswith(表格行末格「不涉及」);至少一行命中
1751
+ * → true;无节/无命中 → false。骨架注释渲染归 task-03,本函数只做解析。
1752
+ * @param {string} md verify-result.md 全文
1753
+ * @returns {boolean}
1754
+ */
1755
+ function parseRuntimeEndpointExcluded(md) {
1756
+ const text = String(md || '').replace(/\r\n/g, '\n')
1757
+ const lines = text.split('\n')
1758
+ const start = lines.findIndex(l => l.startsWith('## Runtime Evidence'))
1759
+ if (start === -1) return false
1760
+ for (let i = start + 1; i < lines.length; i++) {
1761
+ if (/^#{1,2}\s/.test(lines[i])) break // 同 parseEvidenceSlots sectionOf 口径:到下一 #/## 级标题止
1762
+ const t = lines[i].trim()
1763
+ if (!t.startsWith('|')) continue // 只认表格行(X-18 文法)
1764
+ const stripped = t.replace(/[||]\s*$/, '').trim()
1765
+ if (/端点|请求-响应|服务端点/.test(t) && stripped.endsWith('不涉及')) return true
1766
+ }
1767
+ return false
1768
+ }
1769
+
1770
+ /** facts.matrixPartialRows:探针 7 矩阵 verdict∈{partial, uncovered} 行数(无段 → 0) */
1771
+ function countMatrixPartialRows(md) {
1772
+ if (!extractAcceptanceMatrixSlotsFn) {
1773
+ console.warn('⚠️ extractAcceptanceMatrixSlots 动态绑定不可用(stage-contract 加载失败,fail-soft)——facts.matrixPartialRows 本次计 0')
1774
+ return 0
1775
+ }
1776
+ const matrix = extractAcceptanceMatrixSlotsFn(md)
1777
+ return matrix.rows.filter(r => r && (r.verdict === 'partial' || r.verdict === 'uncovered')).length
1778
+ }
1779
+
720
1780
  export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResult = null, conclusion = null }) {
721
1781
  let facts = null
722
1782
  try { facts = JSON.parse(readFileSync(factsPath, 'utf8')) } catch { /* 缺失见下 */ }
@@ -744,6 +1804,28 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
744
1804
  if (slots.hasReceiptSlot && slots.runtimeEvidence.length > 0) {
745
1805
  facts.runtimeEvidence = slots.runtimeEvidence
746
1806
  }
1807
+ // 移交项结构化回填(handover-only-in-prose):有有效行 → facts.handover 落盘(机器可读);
1808
+ // PASS WITH NOTES 零有效行 → advisory 警告(不阻断——存量 PASS WITH NOTES 无此章节是常态,
1809
+ // 渐进采纳)。EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1,清单化才有
1810
+ // 「谁兜、怎么复跑」的可追溯面。
1811
+ const handoverItems = parseHandoverRows(verifyMd || '')
1812
+ if (handoverItems.length > 0) {
1813
+ facts.handover = { count: handoverItems.length, items: handoverItems }
1814
+ }
1815
+ if (conclusionSlot === 'PASS WITH NOTES' && handoverItems.length === 0) {
1816
+ console.warn('⚠️ 结论=PASS WITH NOTES 但「移交项(结构化)」章节零有效行——正文叙述的移交项(环境阻断复跑条件/人工验收步骤/待执行脚本)请结构化进 ## 移交项(结构化) 表格(类型枚举 env-blocked/manual-acceptance/db-script/other),避免移交项只活在 prose 里没人兜(2026-09-15 EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1)。')
1817
+ }
1818
+ // ── PASS 封顶事实面四字段(2026-09-17-pass-cap-semantics task-01 / D-011 producer 侧)──
1819
+ // X-08 时序纪律:全部在函数主路径无条件产出——首次 backfill = gates.js 收尾前置调用、无
1820
+ // testCheckResult 时点(task-02 validator 在 runValidators 时点消费,已就位);误挂二次
1821
+ // 回填的 testCheckResult 分支则消费时点读不到、恒误拦。判定输入自推导自读(quality-scan
1822
+ // 记录按 specBase 推导 / 矩阵经动态绑定),不依赖调用方传参。
1823
+ facts.dbScriptDeclarations = parseDbScriptDeclarations(verifyMd || '')
1824
+ facts.runtimeEndpointExcluded = parseRuntimeEndpointExcluded(verifyMd || '')
1825
+ const integration = judgeIntegrationRan(factsPath, slots.runtimeEvidence)
1826
+ facts.integrationRan = integration.ran
1827
+ for (const note of integration.notes) console.warn(note)
1828
+ facts.matrixPartialRows = countMatrixPartialRows(verifyMd || '')
747
1829
  let testsBackfilled = false
748
1830
  if (testCheckResult && testCheckResult.status && testCheckResult.status !== 'skipped') {
749
1831
  facts.tests = {
@@ -793,7 +1875,12 @@ export function backfillMissingEvidenceSlots(mdPath, requiredEvidenceItems = [])
793
1875
  blocks.push([
794
1876
  RECEIPT_SLOT_HEADING, '[层:自述声明——CLI 一致性校验]', '',
795
1877
  '<!-- integration-critical/deployment-critical 变更必填;其余写「无」 -->',
796
- '- claim: <待填:一句话> | command: <待填:命令> | exit: <待填:0 或非 0> | log: <待填:日志路径>',
1878
+ '<!-- 回执双形态(2026-09-16-friction5-hardening FR-01):下方多行 YAML 形态为推荐写法(字段序无关);',
1879
+ ' 亦认单行管道形态:- claim: <一句话> | command: <命令> | exit: <0 或非 0> | log: <日志路径> -->',
1880
+ '- claim: <待填:一句话>',
1881
+ ' command: <待填:命令>',
1882
+ ' exit: <待填:0 或非 0>',
1883
+ ' log: <待填:日志路径>',
797
1884
  '',
798
1885
  ].join('\n'))
799
1886
  added.push(RECEIPT_SLOT_HEADING)
@@ -870,7 +1957,7 @@ export function applyConclusionDraftToText(text, draft = 'PASS') {
870
1957
  * tasks.md 行内注解可靠)。Evidence / 状态两列是人工判断置 <待填>;D 无 task 回指 →
871
1958
  * ⚠️ 未闭环显式列出(这正是矩阵要暴露的风险,不静默)。
872
1959
  * @returns {{ rows: string[], decisionCount: number, taskCount: number }|null}
873
- * null = 无 decisions.md / 解析 0 条(矩阵段留 TODO 不注入)
1960
+ * null = 无 decisions.md / 解析 0 条(矩阵段留 TODO 不注入) probe1-noqa
874
1961
  */
875
1962
  export function buildDecisionChainMatrix(changeDir) {
876
1963
  let decisions
@@ -912,9 +1999,9 @@ export function buildDecisionChainMatrix(changeDir) {
912
1999
 
913
2000
  /**
914
2001
  * 矩阵机械半边注入 verify-result.md(--init 接线):只在「决策追踪矩阵」段内仍是骨架
915
- * TODO 行(含 D-xxx 形态)且段内无既有表格时替换注入——幂等(agent 已写/前次注入零改动),
2002
+ * TODO 行(含 D-xxx 形态)且段内无既有表格时替换注入——幂等(agent 已写/前次注入零改动), probe1-noqa
916
2003
  * 不触碰正文其余部分。落盘失败 fail-soft 返回 null。
917
- * @returns {{ decisions: number, tasks: number }|null} null = 无 decisions/无可替换 TODO/已注入
2004
+ * @returns {{ decisions: number, tasks: number }|null} null = 无 decisions/无可替换 TODO/已注入 probe1-noqa
918
2005
  */
919
2006
  export function injectDecisionChainDraft(mdPath, changeDir) {
920
2007
  let text
@@ -925,8 +2012,8 @@ export function injectDecisionChainDraft(mdPath, changeDir) {
925
2012
  const sectionEnd = nextHeading === -1 ? text.length : nextHeading
926
2013
  const section = text.slice(headingIdx, sectionEnd)
927
2014
  if (/^\|/m.test(section)) return null // 已有表格 → 尊重既有内容
928
- const todoRe = /<!--TODO:[^\n]*D-xxx[^\n]*-->/
929
- if (!todoRe.test(section)) return null // 无骨架 TODO(手写正文)→ 不动
2015
+ const todoRe = /<!--TODO:[^\n]*D-xxx[^\n]*-->/ /* probe1-noqa */
2016
+ if (!todoRe.test(section)) return null // 无骨架 TODO(手写正文)→ 不动 /* probe1-noqa */
930
2017
  const matrix = buildDecisionChainMatrix(changeDir)
931
2018
  if (!matrix || matrix.rows.length === 0) return null
932
2019
  const block = [
@@ -947,7 +2034,7 @@ export function injectDecisionChainDraft(mdPath, changeDir) {
947
2034
  }
948
2035
 
949
2036
  /**
950
- * 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。
2037
+ * 生成 verify-result.md 骨架(七章节;探针结果机械预填,语义章节 <!--TODO--> 占位)。 probe1-noqa
951
2038
  * 结论走「结论枚举:」固定槽行(刀③,2026-09-08)——占位符不含枚举词(<待填:三选一>),
952
2039
  * 槽未填 → extractVerifyConclusionSlot 返回 '' → gate 判不过(fail-closed;旧占位符
953
2040
  * `<待填:PASS 或 FAIL>` 含 PASS 字样会被窗口正则误读成已填 PASS,已修)。
@@ -959,47 +2046,71 @@ export function generateVerifyResultSkeleton(result) {
959
2046
  const L = [
960
2047
  '# 验证报告(骨架由 `sillyspec verify-probes --change <变更名> --init` 生成)',
961
2048
  '',
962
- '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论只认「结论枚举:」槽行**——',
2049
+ '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论只认「结论枚举:」槽行**——', /* probe1-noqa */
963
2050
  '> 槽行留「<待填:三选一>」会被 gate 判不过(fail-closed),正文其他位置的 PASS/FAIL 字样不参与判定。',
2051
+ '>',
2052
+ '> 「层」=证据可核验性分层(非执行者声明):「人工判断」指本节为语义判断,由执行 agent 填写、',
2053
+ '> CLI 不机械复跑(gate 抽查 + 人类审批点复核兜底);「可复跑探针/确定性检查/CLI 一致性校验」= gate 可机械复核段。',
964
2054
  '',
965
2055
  '## 结论 [层:人工判断]',
966
2056
  '',
967
2057
  '结论枚举:`<待填:三选一>`(把尖括号占位整体替换为 PASS / PASS WITH NOTES / FAIL 之一;一句话理由写在枚举后同行或下一行)',
968
2058
  '',
2059
+ '## 移交项(结构化) [层:人工判断——CLI 清单核验]',
2060
+ '<!-- 结论=PASS WITH NOTES 时本节必填(prose 移交叙述转结构化,复跑/验收有据可查、agent 可恢复复跑);结论=PASS/FAIL 写「无」 -->',
2061
+ '<!-- 类型枚举:env-blocked(环境阻断,条件列必填复跑口径)/ manual-acceptance(人工验收,条件列必填验收步骤)/ db-script(待执行脚本,条件列必填执行环境与顺序)/ other -->',
2062
+ '| 类型 | 条目 | 复跑/验收条件 |',
2063
+ '|---|---|---|',
2064
+ '| <待填:env-blocked / manual-acceptance / db-script / other> | <待填:移交条目> | <待填:复跑/验收条件> |',
2065
+ '',
969
2066
  '## 证据账(cannot_verify 任务) [层:人工判断——CLI 核验]',
970
2067
  '<!-- 无 cannot_verify 任务时本节写「无」;有则逐 task 一行 -->',
971
2068
  '- task-NN: <待填:三选一> | verifiedFiles: <精确路径,逗号分隔>(satisfied 必填;豁免时填 missing 并加(豁免:<一句话理由>)后缀)',
972
2069
  '',
973
2070
  '## 集成验证回执 [层:自述声明——CLI 一致性校验]',
974
2071
  '<!-- integration-critical/deployment-critical 变更必填;其余写「无」 -->',
975
- '- claim: <待填:一句话> | command: <待填:命令> | exit: <待填:0 或非 0> | log: <待填:日志路径>',
2072
+ '<!-- 回执双形态(2026-09-16-friction5-hardening FR-01):下方多行 YAML 形态为推荐写法(字段序无关);',
2073
+ ' 亦认单行管道形态:- claim: <一句话> | command: <命令> | exit: <0 或非 0> | log: <日志路径> -->',
2074
+ '- claim: <待填:一句话>',
2075
+ ' command: <待填:命令>',
2076
+ ' exit: <待填:0 或非 0>',
2077
+ ' log: <待填:日志路径>',
976
2078
  '',
977
2079
  '## 任务完成度 [层:人工判断]',
978
- '<!--TODO: 逐 task 对照 tasks.md 勾选与验收标准,完成/未完成/存疑三态-->',
2080
+ '<!--TODO: 逐 task 对照 tasks.md 勾选与验收标准,完成/未完成/存疑三态-->', /* probe1-noqa */
979
2081
  '',
980
2082
  '## 设计一致性 [层:人工判断]',
981
- '<!--TODO: 实现与 design.md 的偏差(无偏差也显式写「一致」)-->',
2083
+ '<!--TODO: 实现与 design.md 的偏差(无偏差也显式写「一致」)-->', /* probe1-noqa */
982
2084
  '',
983
2085
  '## 探针结果(CLI 机械预填) [层:可复跑探针——gate 抽查防篡改]',
984
2086
  renderVerifyProbesReport(result),
985
2087
  '',
986
2088
  '## 测试结果 [层:确定性检查——CLI 实测对账]',
987
- '<!--TODO: 测试命令 + 结果(通过数/失败数;known_failures 豁免逐条注明)-->',
2089
+ '<!--TODO: 测试命令 + 结果(通过数/失败数;known_failures 豁免逐条注明)-->', /* probe1-noqa */
988
2090
  '',
989
2091
  '## 决策追踪矩阵(如存在 decisions.md;无则删本节) [层:人工判断]',
990
- '<!--TODO: | 决策 ID | FR | Task | Evidence | 状态 |(D-xxx@vN → FR-xxx → task → 证据回指闭环)-->',
2092
+ '<!--TODO: | 决策 ID | FR | Task | Evidence | 状态 |(D-xxx@vN → FR-xxx → task → 证据回指闭环)-->', /* probe1-noqa */
991
2093
  '',
992
2094
  '## 技术债务 [层:人工判断]',
993
- '<!--TODO: TODO/FIXME/HACK 统计(探针 1 的命中已预填在上方探针结果)-->',
2095
+ '<!--TODO: TODO/FIXME/HACK 统计(探针 1 的命中已预填在上方探针结果)-->', /* probe1-noqa */
994
2096
  '',
995
2097
  '## 变更风险等级 [层:人工判断]',
996
- '<!--TODO: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,写明「显式声明 = <等级>」+ 理由;若有命中被同句否定语境抑制(如「不新增 daemon 协议」),写明被抑制关键词与理由(抑制可审计,不许用来静默降级)-->',
2098
+ '<!--TODO: doc-only / unit-sufficient / contract-required / integration-critical / deployment-critical;若 design.md frontmatter 有 risk_level 显式声明,写明「显式声明 = <等级>」+ 理由;若有命中被同句否定语境抑制(如「不新增 daemon 协议」),写明被抑制关键词与理由(抑制可审计,不许用来静默降级)-->', /* probe1-noqa */
997
2099
  '',
998
2100
  '## Runtime Evidence [层:人工判断]',
999
- '<!--TODO: 关键命令输出/时间戳/commit hash 证据链;integration/deployment-critical 必填,按实际触碰的运行时组件写(启动命令/端点/请求响应/日志片段/生命周期终态断言/失败模式排除),未涉及的行写「不涉及」-->',
2101
+ '<!--TODO: 关键命令输出/时间戳/commit hash 证据链;integration/deployment-critical 必填,按实际触碰的运行时组件写(启动命令/端点/请求响应/日志片段/生命周期终态断言/失败模式排除),未涉及的行写「不涉及」-->', /* probe1-noqa */
2102
+ '<!-- 降级路径(design §3.2,D-004 收口):服务起不来时:Controller 直调冒烟(mock 下游,验绑定+校验+路由)/ 基础设施恢复后复跑固化用例——不要空填不涉及 -->',
1000
2103
  '',
1001
2104
  '## 代码审查 [层:人工判断]',
1002
- '<!--TODO: 问题列表 + 总体评价-->',
2105
+ '<!--TODO: 问题列表 + 总体评价。走查清单(零覆盖路径必查——探针 7 ⚠️ 条目即定向面):', /* probe1-noqa */
2106
+ ' ① 编辑/更新链路(回显、字段映射、残留态)——非新增主链路,实证盲区;',
2107
+ ' ② 非主分支流(相关方/旁路支线等未走查路径);',
2108
+ ' ③ 守卫一致性:同资源端点的操作人/权限校验模式对比(实证 doSubmit 无操作人校验而 delete/withdraw 有——越权);',
2109
+ ' ④ 载荷字段契约(探针 8 ⚠️ 配对逐条核实);',
2110
+ ' ⑤ 分页/并发/事务原子性(无测试基建端的纯逻辑面)-->',
2111
+ '',
2112
+ '## 独立复核(可选回流槽) [层:人工判断——复核后追加]',
2113
+ '<!-- verify 完成后的深度复核(独立子代理/二次审查)结论回流至此:缺陷分级(P1 功能不可用 / P2 需求子项 / P3 建议修)+ 修复证据链 + 对「结论枚举」的影响改写。无复核时本节写「无」或删除。复核结论不再只活在聊天记录(2026-09-16 EHS 二次复核实证:5 个 P1 只有聊天可查,变更档案仍写 PASS WITH NOTES)。 -->',
1003
2114
  '',
1004
2115
  ]
1005
2116
  return L.join('\n')