sillyspec 3.28.12 → 3.29.0

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 (47) hide show
  1. package/.claude/skills/sillyspec-execute/SKILL.md +222 -223
  2. package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
  3. package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
  4. package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
  5. package/package.json +1 -1
  6. package/src/blast-surface.js +168 -0
  7. package/src/ceremony-tier.js +359 -0
  8. package/src/change-risk-profile.js +75 -202
  9. package/src/config-schema.js +21 -0
  10. package/src/decision-distill.js +32 -25
  11. package/src/decisions-io.js +32 -0
  12. package/src/design-facts.js +1 -0
  13. package/src/doctor-diagnostics.js +148 -1
  14. package/src/fr-index.js +359 -0
  15. package/src/index.js +92 -5
  16. package/src/knowledge-stats.js +124 -2
  17. package/src/modules.js +28 -0
  18. package/src/prefill.js +491 -0
  19. package/src/probe7-anchor-check.js +8 -6
  20. package/src/quick-gate-profile.js +8 -4
  21. package/src/quick-session-owner.js +63 -0
  22. package/src/review-dispatch.js +473 -1
  23. package/src/review-material-pack.js +265 -0
  24. package/src/review-tier.js +185 -41
  25. package/src/run/archive-distill.js +35 -0
  26. package/src/run/command.js +158 -87
  27. package/src/run/complete-handlers.js +82 -1
  28. package/src/run/complete.js +37 -1
  29. package/src/run/gates.js +301 -19
  30. package/src/run/prompt.js +454 -21
  31. package/src/run/shared.js +36 -0
  32. package/src/run/verify-quality-scan.js +14 -6
  33. package/src/scope-audit.js +8 -1
  34. package/src/span-risk-surface.js +127 -0
  35. package/src/stage-contract-spec.js +2 -2
  36. package/src/stage-contract.js +172 -98
  37. package/src/stage-review.js +26 -2
  38. package/src/stages/brainstorm.js +34 -16
  39. package/src/stages/execute.js +10 -2
  40. package/src/stages/plan.js +29 -12
  41. package/src/stages/verify.js +7 -7
  42. package/src/taskcard.js +3 -0
  43. package/src/verify-postcheck.js +375 -20
  44. package/src/verify-probes.js +964 -30
  45. package/src/worktree.js +1 -1
  46. package/templates/prompts/taskcard-rules.md +1 -0
  47. package/templates/prompts/verify-probes.md +1 -1
@@ -12,6 +12,9 @@
12
12
  * 探针7 验收×测试覆盖矩阵:task 卡 acceptance 自解析(jsYaml,string/array 双形态)+ 双源结构
13
13
  * 归属(allowed_paths 测试模式 ∪ execute-runs review.json changedFiles test/ 前缀)+
14
14
  * 关键词命中提示(命中≠判定,不参与门禁;2026-09-14-acceptance-test-matrix)
15
+ * 探针10 预填注清零(error 门):design.md + tasks/task-*.md 白名单槽宿主文件集逐个
16
+ * hasUnconfirmedPrefill(注在场=未确认)——--init 预填进探针段 + gates.js verify 收尾
17
+ * 复跑同一实现阻断(门禁梯度 error 档;2026-09-18-artifact-prefill task-03)
15
18
  * 探针2(关键词提取半语义)/探针3.4 集成盲区/3.5 断言抽查/探针4(决策追踪语义)留 agent。
16
19
  *
17
20
  * verify-result.md 骨架:七章节固定结构 + 探针结果机械预填 + 其余章节 <!--TODO--> 占位。 probe1-noqa
@@ -22,9 +25,10 @@
22
25
  * 供事后独立复跑审计;重复 --init 覆盖为最近一次 init 快照)。
23
26
  */
24
27
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs'
25
- import { join, dirname, basename, resolve, isAbsolute } from 'path'
28
+ import { join, dirname, basename, resolve, isAbsolute, relative } from 'path'
26
29
  import jsYaml from 'js-yaml'
27
- import { gitQuiet } from './git-helper.js'
30
+ import { gitQuiet, unquoteGitPath } from './git-helper.js'
31
+ import { hasUnconfirmedPrefill } from './prefill.js'
28
32
  import {
29
33
  FACTS_SCHEMA_VERSION, EVIDENCE_SLOT_HEADING, RECEIPT_SLOT_HEADING, parseEvidenceSlots,
30
34
  } from './verify-facts-schema.js'
@@ -32,6 +36,7 @@ import { parseFileChangeListDetailed } from './change-list.js'
32
36
  import { parseDecisions } from './decision-distill.js'
33
37
  import { parseAllowedPaths, parseRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
34
38
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
39
+ import { RECEIPT_SOURCE_CROSS_LAYER_RE as RECEIPT_CROSS_LAYER_RE, RECEIPT_SOURCE_UNIT_RE as RECEIPT_UNIT_RE, CLI_SMOKE_SOURCE_MARK as SMOKE_RECEIPT_SOURCE_MARK } from './change-risk-profile.js'
35
40
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
36
41
  import { resolveSpecDir, resolveRuntimeRoot, detectWorktreeSpecDrift } from './run/shared.js'
37
42
 
@@ -189,6 +194,768 @@ export function extractPayloadKeys(text) {
189
194
  return keys
190
195
  }
191
196
 
197
+ // ── probe8 diff 源采集器(task-01,2026-09-18-probe8-direct-compare / D-001@v1:文件源从
198
+ // design 清单声明面切到 diff 实际面——三态 fallback 链 + 文件分类 + design 差集 advisory)──
199
+ // 文件分类规则(Grill B-10):.java→backend;.js/.jsx/.wxml/.vue→frontend(.vue 无条件
200
+ // frontend,N-2);.ts/.tsx 按目录启发式裁决——目录段词(驼峰拆词,只看目录部分、文件名不
201
+ // 参与——classifyConsumerHints 段级匹配同哲学,'homepages.js' 文件名不命中 pages 段)含
202
+ // routes/pages/components/models→frontend;含 controller/service/mapper/entity/dto/api(含
203
+ // 常见复数形)→backend;目录两不中再看文件头 10 行含 @RequestMapping/@RestController/@Service
204
+ // →backend;仍两不中→other。.css/.less/.json/.md/.sql 等→other。
205
+ const PROBE8_FE_DIR_WORDS = new Set(['routes', 'pages', 'components', 'models'])
206
+ const PROBE8_BE_DIR_WORDS = new Set(['controller', 'controllers', 'service', 'services', 'mapper', 'mappers', 'entity', 'entities', 'dto', 'dtos', 'api', 'apis'])
207
+ const PROBE8_BE_HEAD_RE = /@(?:RequestMapping|RestController|Service)\b/
208
+
209
+ /** 目录段词化(驼峰拆词 + 非字母数字切段;只取目录部分,根级文件无目录词) */
210
+ function probe8DirWords(p) {
211
+ const posix = String(p || '').split('\\').join('/')
212
+ const cut = posix.lastIndexOf('/')
213
+ return (cut === -1 ? '' : posix.slice(0, cut))
214
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
215
+ .toLowerCase()
216
+ .split(/[^a-z0-9]+/)
217
+ .filter(Boolean)
218
+ }
219
+
220
+ /**
221
+ * probe8 文件分类(task-01,纯函数——采集器内部消费;task-02/03 经 collectProbe8DiffFiles 的
222
+ * fileClassification 字段消费,不直接引用本函数)。
223
+ * @param {string} filePath 仓根相对路径(反斜杠容忍)
224
+ * @param {string|null} [headText] 文件头文本(调用方传头 10 行;.ts/.tsx 目录启发式未裁决时消费)
225
+ * @returns {'frontend'|'backend'|'other'}
226
+ */
227
+ function classifyProbe8FilePath(filePath, headText = null) {
228
+ const p = String(filePath || '').split('\\').join('/')
229
+ const lower = p.toLowerCase()
230
+ if (lower.endsWith('.java')) return 'backend'
231
+ if (/\.(js|jsx|wxml|vue)$/.test(lower)) return 'frontend'
232
+ if (/\.(ts|tsx)$/.test(lower)) {
233
+ const words = probe8DirWords(p)
234
+ if (words.some(w => PROBE8_FE_DIR_WORDS.has(w))) return 'frontend'
235
+ if (words.some(w => PROBE8_BE_DIR_WORDS.has(w))) return 'backend'
236
+ if (headText && PROBE8_BE_HEAD_RE.test(headText)) return 'backend'
237
+ }
238
+ return 'other'
239
+ }
240
+
241
+ /**
242
+ * collectProbe8DiffFiles——probe8 文件源 diff 实际面采集器(task-01,D-001@v1)。三态 fallback 链
243
+ * (各级 fail-open 不抛):
244
+ * ① diff——worktree 可用(_readWorktreeMeta 命中且 gitDir≠主仓 cwd;in-place-fallback/仓
245
+ * 已删的 meta 落回 ②):gitQuiet 数组参数取 diff baseline..HEAD ∪ status --porcelain
246
+ * (-uall 防目录折叠,同构先例 _resolveDiffFilesForParity,contract-matrix.js);
247
+ * ② in-place——worktree 缺失:主仓 diff HEAD~1..HEAD ∪ porcelain(含已提交窗口,Grill B-9);
248
+ * ③ design-list——主仓两路 git 取数全失败:退 design 清单声明面(parseFileChangeListDetailed)
249
+ * + 模式注记「文件源=design 清单(diff 不可用)」(fail-open 不抛)。
250
+ * 跨仓双源直采(①②态启用,不 import run/ 目录):parseRepoRegistry 读 local.yaml repos →
251
+ * 每注册仓根 git diff HEAD~1..HEAD ∪ porcelain(复刻 collectCrossRepoDiffRoots 只读口径,
252
+ * run/complete.js 同款);仓根不存在/取数失败跳过不炸;产物仓根相对路径。
253
+ * design 差集(advisory,不参与对账不阻断):design 清单有但 diff 面无的路径收 designOnlyPaths
254
+ * (跨仓条目带 cross-repo:<key>: 前缀消歧);design-list 态文件源即清单面 → 差集恒空。
255
+ * 产物路径一律 unquoteGitPath + 正斜杠归一(quotepath=false 下仍防控制字符引号形态)。
256
+ * @param {{ cwd: string, changeName: string, specBase: string, repoKeys?: string[]|null }} args
257
+ * @returns {{ source: 'diff'|'in-place'|'design-list', fallbackMode: 'diff'|'in-place'|'design-list',
258
+ * diffFiles: string[], fileClassification: {frontend: string[], backend: string[], other: string[]},
259
+ * frontend: string[], backend: string[], other: string[], designOnlyPaths: string[], notes: string[] }}
260
+ */
261
+ export function collectProbe8DiffFiles({ cwd, changeName, specBase, repoKeys = null }) {
262
+ const out = {
263
+ source: 'design-list', fallbackMode: 'design-list', diffFiles: [],
264
+ fileClassification: { frontend: [], backend: [], other: [] },
265
+ frontend: [], backend: [], other: [], designOnlyPaths: [], notes: [],
266
+ }
267
+ if (!cwd || !specBase || !changeName) {
268
+ out.notes.push('collectProbe8DiffFiles 入参缺失(cwd/specBase/changeName)——文件源空面')
269
+ return out
270
+ }
271
+
272
+ // 路径归一:剥外层引号 → unquoteGitPath 解码转义(八进制/\"/\\)→ 正斜杠归一
273
+ const normGitPath = (raw) => unquoteGitPath(String(raw || '').trim().replace(/^"|"$/g, '')).split('\\').join('/')
274
+ // porcelain 行:XY <path>(前 3 字符状态码,trim:false 保前导空格;重命名 old -> new 取 new)
275
+ // ——口径同 _resolveDiffFilesForParity(contract-matrix.js)
276
+ const fromPorcelain = (stOut) => {
277
+ const files = []
278
+ for (const line of String(stOut || '').split('\n')) {
279
+ if (!line || line.length < 4) continue
280
+ const p = line.slice(3).trim().split(' -> ').pop() || ''
281
+ if (p) files.push(normGitPath(p))
282
+ }
283
+ return files
284
+ }
285
+ // git 双源采集:diff --name-only ∪ status --porcelain -uall;两取数任一成功即 ok(全失败
286
+ // 返 null 交 fallback 链降级);产物 Set 去重
287
+ const gitFace = (gitDir, diffSpec) => {
288
+ let ok = false
289
+ const files = new Set()
290
+ const d = gitQuiet(gitDir, ['diff', '--name-only', diffSpec], { timeout: 30000 })
291
+ if (d !== null) {
292
+ ok = true
293
+ for (const line of String(d).split('\n')) {
294
+ const f = normGitPath(line)
295
+ if (f) files.add(f)
296
+ }
297
+ }
298
+ const st = gitQuiet(gitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
299
+ if (st !== null) {
300
+ ok = true
301
+ for (const f of fromPorcelain(st)) files.add(f)
302
+ }
303
+ return ok ? [...files] : null
304
+ }
305
+
306
+ // ── 主仓三态 fallback ──
307
+ let source = null
308
+ let mainRoot = cwd
309
+ const mainFiles = []
310
+ const wt = _readWorktreeMeta(specBase, cwd, changeName)
311
+ if (wt && resolve(wt.gitDir) !== resolve(cwd)) {
312
+ // baseline 口径同 _resolveDiffFilesForParity:baselineCommit / actualBaseHash / baseHash
313
+ const diffBase = wt.meta.baselineCommit || wt.meta.actualBaseHash || wt.meta.baseHash
314
+ if (diffBase) {
315
+ const face = gitFace(wt.gitDir, `${diffBase}..HEAD`)
316
+ if (face) {
317
+ source = 'diff'
318
+ mainRoot = wt.gitDir
319
+ mainFiles.push(...face)
320
+ out.notes.push(`文件源=worktree diff(${diffBase.slice(0, 8)}..HEAD ∪ porcelain)`)
321
+ }
322
+ }
323
+ }
324
+ if (!source) {
325
+ // in-place(Grill B-9 含已提交窗口):HEAD~1..HEAD ∪ porcelain(初提交仓 HEAD~1 不存在时
326
+ // 两取数全失败 → 落 design-list)
327
+ const face = gitFace(cwd, 'HEAD~1..HEAD')
328
+ if (face) {
329
+ source = 'in-place'
330
+ mainFiles.push(...face)
331
+ out.notes.push('文件源=in-place diff(HEAD~1..HEAD ∪ porcelain,含已提交窗口)')
332
+ }
333
+ }
334
+
335
+ // ── local.yaml 注册表(跨仓直采 + design 清单 repoKeys 解析共用;specBase/local.yaml 优先,
336
+ // 兼容 cwd/.sillyspec/local.yaml——runProbe8PayloadParity / collectCrossRepoDiffRoots 双读点合一)──
337
+ let registry = new Map()
338
+ try {
339
+ for (const yamlPath of [join(specBase, 'local.yaml'), join(cwd, '.sillyspec', 'local.yaml')]) {
340
+ if (!existsSync(yamlPath)) continue
341
+ registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
342
+ if (registry.size > 0) break
343
+ }
344
+ } catch { /* 注册表不可读 → 跨仓空面(fail-open) */ }
345
+ const repoRootOf = (raw) => (isAbsolute(raw) ? raw : resolve(cwd, raw))
346
+
347
+ // ── design 清单声明面(③态文件源 + 差集基准;NEW: 待建前缀剥除,probe1-new-prefix-miss 同款)──
348
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
349
+ let designList = []
350
+ try {
351
+ if (existsSync(designPath)) designList = parseFileChangeListDetailed(designPath, { repoKeys: repoKeys || [...registry.keys()] })
352
+ } catch { designList = [] }
353
+ const designNorm = []
354
+ for (const e of designList) {
355
+ let p = String((e && e.path) || '').replace(/^NEW:\s*/, '').split('\\').join('/').trim()
356
+ let repo = (e && e.repo) || null
357
+ if (!p || p.startsWith('.sillyspec/')) continue
358
+ // 未注册跨仓前缀兜底识别(change-list 只对注册 key 落 e.repo):前缀剥掉按路径启发式
359
+ // 分类 + 未注册注记(runProbe8 readEntry 同款现身原则,不静默吞)
360
+ if (!repo) {
361
+ const cr = p.match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
362
+ if (cr) {
363
+ p = cr[2]
364
+ out.notes.push(`repo「${cr[1]}」未在 local.yaml repos 注册——该仓文件不进 diff 面,按路径启发式分类`)
365
+ }
366
+ }
367
+ if (p) designNorm.push({ repo, path: p })
368
+ }
369
+
370
+ // ── 跨仓双源直采(diff/in-place 态;design-list 态文件源即清单面不直采)──
371
+ const fileEntries = [] // { path, roots[] }(roots = 文件头 10 行读取候选根,先主后次)
372
+ if (source === 'diff' || source === 'in-place') {
373
+ for (const f of mainFiles) fileEntries.push({ path: f, roots: [mainRoot] })
374
+ for (const [key, raw] of registry.entries()) {
375
+ try {
376
+ if (!raw) continue
377
+ const root = repoRootOf(raw)
378
+ if (!root || !existsSync(root) || resolve(root) === resolve(cwd)) continue // 主仓自身不重复计
379
+ const face = gitFace(root, 'HEAD~1..HEAD')
380
+ if (!face) { out.notes.push(`跨仓「${key}」diff 取数失败——跳过`); continue }
381
+ for (const f of face) fileEntries.push({ path: f, roots: [root] })
382
+ } catch { out.notes.push(`跨仓「${key}」采集异常——跳过`) }
383
+ }
384
+ } else {
385
+ out.notes.push('文件源=design 清单(diff 不可用)')
386
+ for (const e of designNorm) {
387
+ const roots = e.repo && registry.has(e.repo)
388
+ ? [repoRootOf(registry.get(e.repo))]
389
+ : [wt && resolve(wt.gitDir) !== resolve(cwd) ? wt.gitDir : null, cwd].filter(Boolean)
390
+ fileEntries.push({ path: e.path, roots })
391
+ }
392
+ }
393
+
394
+ // ── design 差集(advisory):design 清单有但 diff 面无(design-list 态清单即面 → 恒空)──
395
+ if (source === 'diff' || source === 'in-place') {
396
+ // 主仓面直接对集合;跨仓面按 roots[0] 反查仓根对应的 fileEntries 集合(多仓同根退化为主
397
+ // 仓口径,advisory 可接受;未注册 repo 无根可查 → 其条目恒落 design-only,如实现身不静默吞)
398
+ const mainFace = new Set(mainFiles)
399
+ const faceByRoot = new Map()
400
+ const faceAt = (root) => {
401
+ const abs = resolve(root)
402
+ if (!faceByRoot.has(abs)) {
403
+ faceByRoot.set(abs, new Set(fileEntries.filter(fe => resolve(fe.roots[0]) === abs).map(fe => fe.path)))
404
+ }
405
+ return faceByRoot.get(abs)
406
+ }
407
+ for (const e of designNorm) {
408
+ if (e.repo) {
409
+ const face = registry.has(e.repo) ? faceAt(repoRootOf(registry.get(e.repo))) : new Set()
410
+ if (!face.has(e.path)) out.designOnlyPaths.push(`cross-repo:${e.repo}:${e.path}`)
411
+ } else if (!mainFace.has(e.path)) {
412
+ out.designOnlyPaths.push(e.path)
413
+ }
414
+ }
415
+ }
416
+
417
+ // ── 分类(.ts/.tsx 目录启发式未裁决时才读文件头 10 行,省 IO;读不到按无头处理)──
418
+ const head10 = (roots, relPath) => {
419
+ for (const r of roots || []) {
420
+ try { return readFileSync(join(r, relPath), 'utf8').split('\n').slice(0, 10).join('\n') } catch { /* 试下一根 */ }
421
+ }
422
+ return null
423
+ }
424
+ const cls = { frontend: [], backend: [], other: [] }
425
+ const seen = new Set()
426
+ for (const fe of fileEntries) {
427
+ if (seen.has(fe.path)) continue
428
+ seen.add(fe.path)
429
+ let c
430
+ if (/\.(ts|tsx)$/i.test(fe.path)) {
431
+ const words = probe8DirWords(fe.path)
432
+ const dirResolved = words.some(w => PROBE8_FE_DIR_WORDS.has(w) || PROBE8_BE_DIR_WORDS.has(w))
433
+ c = classifyProbe8FilePath(fe.path, dirResolved ? null : head10(fe.roots, fe.path))
434
+ } else {
435
+ c = classifyProbe8FilePath(fe.path)
436
+ }
437
+ cls[c].push(fe.path)
438
+ }
439
+ out.source = source || 'design-list'
440
+ out.fallbackMode = out.source
441
+ out.diffFiles = [...seen].sort()
442
+ out.frontend = cls.frontend
443
+ out.backend = cls.backend
444
+ out.other = cls.other
445
+ out.fileClassification = { frontend: cls.frontend, backend: cls.backend, other: cls.other }
446
+ out.designOnlyPaths.sort()
447
+ return out
448
+ }
449
+
450
+ // ── probe8 前端载荷字段提取器(task-02,2026-09-18-probe8-direct-compare / D-002@v1)──
451
+ // 前端面从「单文件键集」(extractPayloadKeys)升级为「字段 + 首命中行号 + URL 关联素材」三元组,
452
+ // 供 task-04 前端×后端直查对账。三后缀选匹配族(.js/.ts/.jsx/.tsx 基础四形态;.vue 追加
453
+ // v-model/prop;.wxml 追加 value 插值/data- 前缀);DTO 键仅收请求调用 8 行邻近窗口
454
+ // (R-08/Grill B-5:全文件字面量键污染防线,窗口机制对齐 extractPayloadKeys)。
455
+ // 请求调用定位族——词边界前置防 myDelete(/onSearch( 类标识符内误命中(EHS onDelete 形态)
456
+ const PROBE8_REQUEST_CALL_RE = /\b(?:fetch|post|put|delete|getRequest|postRequest|putRequest|deleteRequest|search)\s*\(/
457
+ // DTO 对象字面量起始键({ key: 形态,{ 与键同行);控制流/声明关键字经保留字 Set 排除
458
+ const PROBE8_DTO_KEY_RE = /[{]\s*([a-zA-Z]\w+)\s*:/g
459
+ const PROBE8_DTO_KEY_RESERVED = new Set([
460
+ 'if', 'for', 'while', 'switch', 'return', 'function', 'const', 'let', 'var', 'new', 'typeof',
461
+ 'await', 'try', 'catch', 'else', 'do', 'case', 'default', 'export', 'import', 'class',
462
+ 'extends', 'super', 'this', 'null', 'undefined', 'true', 'false',
463
+ ])
464
+ // js 族基础三正则(+ 上方 DTO 邻近窗口族);带 g 供 matchAll(matchAll 不推进原正则 lastIndex)
465
+ const PROBE8_FE_FORMDATA_RE = /formData\.([a-zA-Z]\w+)/g
466
+ const PROBE8_FE_PAYLOAD_RE = /payload\.([a-zA-Z]\w+)/g
467
+ const PROBE8_FE_NAME_RE = /name="(\w+)"/g
468
+ // vue 追加:v-model 绑定值 + prop 属性值((?<![:\w]) 排 vue 动态绑定 :prop= 与连写词内 prop=)
469
+ const PROBE8_FE_VMODEL_RE = /v-model="(\w+)"/g
470
+ const PROBE8_FE_PROP_RE = /(?<![:\w])prop="(\w+)"/g
471
+ // wxml 追加:value 插值绑定值 + data- 前缀属性名(事件传参 data-xxx= 形态)
472
+ const PROBE8_FE_WXML_VALUE_RE = /value="{{(\w+)}}"/g
473
+ const PROBE8_FE_WXML_DATA_RE = /data-(\w+)=/g
474
+ // URL 捕获:请求调用首参(引号/反引号/斜杠起始;变量首参不收,fail-visible 保守面)
475
+ const PROBE8_URL_CALL_RE = /\b(?:fetch|post|put|delete|getRequest|postRequest|putRequest|deleteRequest|search)\s*\(\s*['"`/]([^'"`,\s]+)/g
476
+
477
+ /** 字段名归一:snake_case → lowerCamel(_x → X,_ 后随字母数字大写化) */
478
+ const camelProbe8Field = (s) => String(s || '').replace(/_([A-Za-z0-9])/g, (_, c) => c.toUpperCase())
479
+
480
+ /** URL 归一化三步:去 ?query → 去首 /api/ 前缀 → 去尾斜杠(task-04 段边界匹配消费口径) */
481
+ const normalizeProbe8Url = (u) => String(u || '')
482
+ .split('?')[0]
483
+ .replace(/^\/?api\//, '')
484
+ .replace(/\/+$/, '')
485
+
486
+ /**
487
+ * 段边界后缀匹配(task-02):前端归一 URL 是否为后端完整 path 的路径段边界后缀——
488
+ * backendPath === frontendUrl ‖ 以 '/'+frontendUrl 收尾 ‖ 以 frontendUrl+'/' 收尾。
489
+ * 段首对齐:'/orders' 命中 '/api/v1/orders' 不命中 '/rporders'(裸 endsWith 的段中假阳防线)。
490
+ * @param {string} frontendUrl 前端归一 URL
491
+ * @param {string} backendPath 后端完整 path(类级+方法级 @RequestMapping 拼接形态)
492
+ * @returns {boolean}
493
+ */
494
+ export function isSegmentSuffix(frontendUrl, backendPath) {
495
+ // 前后都归一去前导/尾随斜杠再比——修前导斜杠缺口(task-06 发现:'/' + '/orders'
496
+ // 拼成 '//orders' 永不命中,非 /api/ 前缀前端 URL 对 /api 前缀后端端点静默失联)
497
+ const fe = String(frontendUrl || '').trim().replace(/^\/+|\/+$/g, '')
498
+ const be = String(backendPath || '').trim().replace(/^\/+|\/+$/g, '')
499
+ if (!fe || !be) return false
500
+ return be === fe || be.endsWith('/' + fe)
501
+ }
502
+
503
+ /**
504
+ * extractFrontendPayloadFields——前端载荷构造点字段提取(task-02)。
505
+ * - 逃生门:文件前 5 行任意一行含 probe8-skip → escapeHatch=true 整文件跳过
506
+ * (probe9-skip 文件级豁免同款先例;不限定注释形态——js/vue/wxml 注释语法各异)。
507
+ * - fields:归一(snake→lowerCamel)后字段名数组(首命中序,Set 语义去重);
508
+ * - fieldLines:Map<归一字段, 首命中行号(1-based,多族命中取最小行号)>——task-04
509
+ * driftWarnings 行号来源;
510
+ * - urlsByCall:{ url, normalizedUrl, line }[] 逐请求调用记录——无 URL 文件产出空数组
511
+ * (未关联端点面处置归 task-04)。
512
+ * 三后缀之外的文件返回空面(escapeHatch=false)。
513
+ * @param {string} filePath 仓根相对路径(反斜杠/大小写容忍)
514
+ * @param {string} content 文件全文
515
+ * @returns {{ escapeHatch: boolean, fields: string[], fieldLines: Map<string, number>,
516
+ * urlsByCall: Array<{ url: string, normalizedUrl: string, line: number}> }}
517
+ */
518
+ export function extractFrontendPayloadFields(filePath, content) {
519
+ const text = String(content || '')
520
+ // 前 5 行逃生门(slice(0,5) 即第 1-5 行)
521
+ if (text.split('\n').slice(0, 5).some(l => l.includes('probe8-skip'))) {
522
+ return { escapeHatch: true, fields: [], fieldLines: new Map(), urlsByCall: [] }
523
+ }
524
+ const p = String(filePath || '').split('\\').join('/').toLowerCase()
525
+ const isJs = /\.(?:js|ts|jsx|tsx)$/.test(p)
526
+ const isVue = p.endsWith('.vue')
527
+ const isWxml = p.endsWith('.wxml')
528
+ if (!isJs && !isVue && !isWxml) {
529
+ return { escapeHatch: false, fields: [], fieldLines: new Map(), urlsByCall: [] }
530
+ }
531
+
532
+ const lines = text.split('\n')
533
+ const fieldLines = new Map() // 归一字段 → 首命中行号(行级族与 DTO 族独立扫描,合并取最小)
534
+ const record = (raw, line) => {
535
+ const f = camelProbe8Field(raw)
536
+ if (!f) return
537
+ const prev = fieldLines.get(f)
538
+ if (prev === undefined || line < prev) fieldLines.set(f, line)
539
+ }
540
+
541
+ // ── 行级匹配族:js 基础三正则(formData./payload./name=)+ vue/wxml 追加族 ──
542
+ const lineRes = [PROBE8_FE_FORMDATA_RE, PROBE8_FE_PAYLOAD_RE, PROBE8_FE_NAME_RE]
543
+ if (isVue) lineRes.push(PROBE8_FE_VMODEL_RE, PROBE8_FE_PROP_RE)
544
+ if (isWxml) lineRes.push(PROBE8_FE_WXML_VALUE_RE, PROBE8_FE_WXML_DATA_RE)
545
+ for (let i = 0; i < lines.length; i++) {
546
+ for (const re of lineRes) {
547
+ for (const m of lines[i].matchAll(re)) record(m[1], i + 1)
548
+ }
549
+ }
550
+
551
+ // ── 请求调用:调用行起 8 行窗口 DTO 键(窗口口径对齐 extractPayloadKeys)+ 首参 URL 捕获 ──
552
+ const urlsByCall = []
553
+ for (let i = 0; i < lines.length; i++) {
554
+ if (!PROBE8_REQUEST_CALL_RE.test(lines[i])) continue
555
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
556
+ for (const m of lines[j].matchAll(PROBE8_DTO_KEY_RE)) {
557
+ if (!PROBE8_DTO_KEY_RESERVED.has(m[1])) record(m[1], j + 1)
558
+ }
559
+ }
560
+ for (const um of lines[i].matchAll(PROBE8_URL_CALL_RE)) {
561
+ urlsByCall.push({ url: um[1], normalizedUrl: normalizeProbe8Url(um[1]), line: i + 1 })
562
+ }
563
+ }
564
+ return { escapeHatch: false, fields: [...fieldLines.keys()], fieldLines, urlsByCall }
565
+ }
566
+
567
+ // ── probe8 后端字段两趟提取器(task-03,2026-09-18-probe8-direct-compare / D-003@v1)──
568
+ // 后端面两趟结构(Grill B-4——@RequestBody 类型名跨文件解析,单文件签名不可行):一趟 Controller
569
+ // 定位(类级 @RequestMapping 前缀 × 方法级动词注解 path 拼接完整端点)+ 方法签名区参数提取
570
+ // (Grill B-3 修正正则);二趟 @RequestBody 类型名经 resolveTypeContent 全仓回调解析出字段集
571
+ // (R-07——变更不动实体时不再全量假阳:只解析被引用类型非全仓字段;全仓性体现在检索面由回调
572
+ // 承担)。函数本体零文件系统 IO(纯函数可测)——全仓检索经回调注入,工厂 createTypeResolver
573
+ // 另行导出供调用侧组装(task-05 接线)。
574
+ // 类级前缀:@RequestMapping(value = "/x") 且位置在 class 声明前(首个命中)
575
+ const PROBE8_CLASS_PREFIX_RE = /@RequestMapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']/
576
+ // class 声明锚(前缀只认首个 class/interface/enum 声明之前;方法级注解按 index 与之比较)
577
+ const PROBE8_CLASS_DECL_RE = /\b(?:class|interface|enum)\s+\w+/
578
+ // 含任一 mapping 注解 → 本文件进一趟端点提取(service/entity 的 .java 不含,天然排除)
579
+ const PROBE8_CONTROLLER_RE = /@(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\b/
580
+ // 方法级端点(动词组含 Request → 无动词时 'REQUEST'):注解 path → 注解余段 [^)]* → 方法签名
581
+ // 可见性修饰符。m[2]=方法子路径;m[0] 尾部止于可见性修饰符后(签名区起点 = m.index+m[0].length)
582
+ const PROBE8_METHOD_MAPPING_RE = /@(Get|Post|Put|Delete|Patch|Request)Mapping\(\s*(?:value\s*=\s*)?["']([^"']+)["'][^)]*\)\s*(?:public|protected|private)\s+/g
583
+ // 方法参数三注解(Grill B-3 修正——捕获组 2=参数名:可选注解参数串 + 可选 final + 泛型/数组
584
+ // 类型(Grill F-6——String[]/List<String> 方括号泛型边界不误捕)+ 最后一个标识符;@RequestParam
585
+ // 注解参数串含 required=false → 该参数非必填)
586
+ const PROBE8_REQUEST_PARAM_RE = /@RequestParam\s*(?:\(([^)]*)\))?\s+(?:final\s+)?(?:\w+(?:<[^>]+>)?(?:\[\])?)\s+(\w+)/g
587
+ // @RequestParam 注解显式 value 名命中时参数名取它(括号紧邻裸串或 value= 形态;defaultValue=
588
+ // 等其他键不命中——括号紧邻约束保证)
589
+ const PROBE8_REQUEST_PARAM_VALUE_RE = /@RequestParam\(\s*(?:value\s*=\s*)?["'](\w+)["']/
590
+ const PROBE8_REQUIRED_FALSE_RE = /required\s*=\s*false/
591
+ const PROBE8_PATH_VARIABLE_RE = /@PathVariable\s+(?:final\s+)?(?:\w+\s+)?(\w+)/g
592
+ const PROBE8_REQUEST_BODY_RE = /@RequestBody\s+(?:final\s+)?(\w+)/g
593
+ // 必填形态①(D-003):校验注解行起向下 5 行窗口内首个 private 字段声明
594
+ const PROBE8_NOT_NULL_RE = /@(?:NotNull|NotBlank|NotEmpty)\b/
595
+ const PROBE8_PRIVATE_FIELD_RE = /private\s+\w+(?:<[^>]+>)?(?:\[\])?\s+(\w+)\s*;/
596
+ // 必填形态③:方法体前 30 行校验调用三模式(StringBlankValidator 首参 / Valid.valid 同行或
597
+ // 上一行字段名 / if 判空字段名)
598
+ const PROBE8_SBV_RE = /StringBlankValidator\s*\(\s*["'](\w+)["']/
599
+ const PROBE8_VALID_LINE_RE = /\bValid\.valid/
600
+ const PROBE8_VALID_ARG_RE = /\bValid\.valid\w*\(\s*(\w+)/
601
+ // 上一行标识符宽收(形态③「上一行字段名」落点):小写起始 + 不紧邻 `(`(排调用名)+ 停用词集
602
+ const PROBE8_VALID_IDENT_RE = /\b([a-z][A-Za-z0-9]*)\b(?!\s*\()/g
603
+ const PROBE8_VALID_IDENT_STOP = new Set([
604
+ 'valid', 'final', 'new', 'return', 'if', 'else', 'while', 'for', 'switch', 'case', 'catch',
605
+ 'null', 'true', 'false', 'this', 'super', 'static', 'public', 'private', 'protected',
606
+ 'string', 'integer', 'int', 'long', 'double', 'float', 'boolean', 'date', 'object',
607
+ ])
608
+ const PROBE8_IF_NULL_RE = /if\s*\(\s*(\w+)\s*==\s*null/
609
+ // 实体/dto 文件形(diff 面直接解析不依赖 @RequestBody 引用链)
610
+ const PROBE8_ENTITY_CLASS_RE = /class\s+\w+\s*(?:extends|implements|\{)/
611
+
612
+ /** 端点 path 拼接:prefix × 方法子路径,去重复 /("/api/" + "/add" → "/api/add") */
613
+ function joinProbe8Path(prefix, methodPath) {
614
+ const p = String(prefix || '')
615
+ const m = String(methodPath || '')
616
+ if (!p) return m
617
+ if (!m) return p
618
+ return (p.endsWith('/') ? p.slice(0, -1) : p) + (m.startsWith('/') ? m : '/' + m)
619
+ }
620
+
621
+ /** private 字段声明名集(二趟 DTO / diff 面实体共用;g 正则局部化 matchAll,无共享 lastIndex 风险) */
622
+ function probe8PrivateFieldNames(content) {
623
+ const out = new Set()
624
+ for (const m of String(content || '').matchAll(/private\s+\w+(?:<[^>]+>)?(?:\[\])?\s+(\w+)\s*;/g)) out.add(m[1])
625
+ return out
626
+ }
627
+
628
+ /** 必填形态①(D-003):@(NotNull|NotBlank|NotEmpty) 行起向下 5 行窗口内首个 private 字段声明名集 */
629
+ function probe8NotNullFields(content) {
630
+ const lines = String(content || '').split('\n')
631
+ const out = new Set()
632
+ for (let i = 0; i < lines.length; i++) {
633
+ if (!PROBE8_NOT_NULL_RE.test(lines[i])) continue
634
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
635
+ const fm = lines[j].match(PROBE8_PRIVATE_FIELD_RE)
636
+ if (fm) { out.add(fm[1]); break }
637
+ }
638
+ }
639
+ return out
640
+ }
641
+
642
+ /**
643
+ * 必填形态③(D-003):方法体前 30 行校验调用三模式字段名集——
644
+ * StringBlankValidator("x") 首参 / Valid.valid 同行首参 + 上一行标识符宽收(R-02 宁多勿漏)/
645
+ * if (x == null) 判空字段。
646
+ */
647
+ function probe8BodyCheckFields(winLines) {
648
+ const out = new Set()
649
+ for (let k = 0; k < winLines.length; k++) {
650
+ const line = winLines[k]
651
+ const sbv = line.match(PROBE8_SBV_RE)
652
+ if (sbv) out.add(sbv[1])
653
+ const ifn = line.match(PROBE8_IF_NULL_RE)
654
+ if (ifn) out.add(ifn[1])
655
+ if (PROBE8_VALID_LINE_RE.test(line)) {
656
+ const arg = line.match(PROBE8_VALID_ARG_RE)
657
+ if (arg) out.add(arg[1])
658
+ if (k > 0) {
659
+ for (const idm of winLines[k - 1].matchAll(PROBE8_VALID_IDENT_RE)) {
660
+ if (!PROBE8_VALID_IDENT_STOP.has(idm[1])) out.add(idm[1])
661
+ }
662
+ }
663
+ }
664
+ }
665
+ return out
666
+ }
667
+
668
+ /**
669
+ * extractBackendFields——后端面两趟提取(task-03,D-003@v1)。
670
+ * - 逃生门:文件前 5 行任意一行含 probe8-skip → escapeHatchCount++ 整文件跳过(与前端
671
+ * task-02 同门,不限定注释形态;优先于非 Java 判定——hatch 是整文件主动退出);
672
+ * - 非 Java:分类面 backend 但非 .java(目录启发式命中的 .ts 等)→ nonJavaSkipCount++;
673
+ * - 一趟 Controller(含六个 mapping 注解任一):类级 @RequestMapping 前缀 × 方法级动词注解
674
+ * path 拼接完整端点(去重复 /);方法级 @RequestMapping → method='REQUEST';prefix 存在
675
+ * 但零方法级端点 → 类级通配 {prefix, 'REQUEST'};方法签名区(注解尾至方法体首 `{`)提取
676
+ * @RequestParam(三态:正常参数名 / required=false 非必填 / value 注解显式名)/ @PathVariable
677
+ * (URL 段参数,非载荷字段只进参数集)/ @RequestBody(类型名收二趟待解析集);
678
+ * - 必填三形态(D-003):①校验注解 5 行窗口(二趟类型内容上扫,命中归引用该类型的端点);
679
+ * ②@RequestParam 排除 required=false 后全必填(Spring 缺省 true);③方法体前 30 行校验
680
+ * 调用三模式;
681
+ * - 二趟:@RequestBody 类型名经 resolveTypeContent(typeName) 全仓回调解析(返 null / 抛错
682
+ * 均 fail-soft 不炸)→ private 字段集 = 端点 bodyFields(并入 entityFields)+ ①必填扫描;
683
+ * diff 面非 Controller 实体文件(class 声明形)直接解析(不依赖 @RequestBody 引用链);
684
+ * - backendAllFields = 二趟类型字段 ∪ 一趟 Controller 方法参数名 ∪ diff 面实体字段
685
+ * (全仓并集非仅 diff 面,Grill B-7)。
686
+ * @param {Array<{path: string, content: string}>} files 分类面 backend 文件集
687
+ * @param {(typeName: string) => string|null} [resolveTypeContent] 二趟全仓类型定义查找回调
688
+ * @returns {{ entityFields: string[], endpoints: Array<{path: string, method: string,
689
+ * requiredFields: string[], bodyFields: string[]}>, backendAllFields: string[],
690
+ * escapeHatchCount: number, nonJavaSkipCount: number }}
691
+ */
692
+ export function extractBackendFields(files, resolveTypeContent) {
693
+ const resolve = typeof resolveTypeContent === 'function' ? resolveTypeContent : () => null
694
+ const entityFields = new Set() // 二趟类型字段 ∪ diff 面实体字段
695
+ const controllerParams = new Set() // 一趟全部 Controller 方法参数名
696
+ const endpoints = [] // {path, method, required:Set, bodyFields:Set, bodyTypes:string[]}
697
+ let escapeHatchCount = 0
698
+ let nonJavaSkipCount = 0
699
+
700
+ for (const f of Array.isArray(files) ? files : []) {
701
+ const path = String((f && f.path) || '').split('\\').join('/')
702
+ const text = String((f && f.content) || '')
703
+ if (text.split('\n').slice(0, 5).some(l => l.includes('probe8-skip'))) { escapeHatchCount++; continue }
704
+ if (!path.toLowerCase().endsWith('.java')) { nonJavaSkipCount++; continue }
705
+
706
+ if (!PROBE8_CONTROLLER_RE.test(text)) {
707
+ // 非 Controller:diff 面实体/dto 直接解析(class 声明形,不依赖 @RequestBody 引用链)
708
+ if (PROBE8_ENTITY_CLASS_RE.test(text)) {
709
+ for (const name of probe8PrivateFieldNames(text)) entityFields.add(name)
710
+ }
711
+ continue
712
+ }
713
+
714
+ // ── 一趟:类级前缀 + 方法级端点 ──
715
+ const lines = text.split('\n')
716
+ const classDecl = text.match(PROBE8_CLASS_DECL_RE)
717
+ const classIdx = classDecl ? classDecl.index : -1
718
+ let prefix = ''
719
+ const pm = text.match(PROBE8_CLASS_PREFIX_RE)
720
+ if (pm && (classIdx < 0 || pm.index < classIdx)) prefix = pm[1]
721
+
722
+ let fileEndpointCount = 0
723
+ for (const m of text.matchAll(PROBE8_METHOD_MAPPING_RE)) {
724
+ if (classIdx >= 0 && m.index < classIdx) continue // 类级 @RequestMapping 非方法端点
725
+ const method = m[1] === 'Request' ? 'REQUEST' : m[1].toUpperCase()
726
+ // 方法签名区:注解尾(m[0] 止于可见性修饰符)至方法体首 `{`;无 `{`(抽象/接口)取到文末
727
+ const sigStart = m.index + m[0].length
728
+ const braceIdx = text.indexOf('{', sigStart)
729
+ const sigText = text.slice(sigStart, braceIdx < 0 ? text.length : braceIdx)
730
+ const ep = {
731
+ path: joinProbe8Path(prefix, m[2]),
732
+ method,
733
+ required: new Set(),
734
+ bodyFields: new Set(),
735
+ bodyTypes: [],
736
+ }
737
+ // @RequestParam 三态(数组/泛型边界不误捕;value 注解显式名优先于声明参数名)
738
+ for (const rp of sigText.matchAll(PROBE8_REQUEST_PARAM_RE)) {
739
+ const annoSeg = rp[1] === undefined ? '' : `@RequestParam(${rp[1]})`
740
+ const vm = annoSeg ? annoSeg.match(PROBE8_REQUEST_PARAM_VALUE_RE) : null
741
+ const name = vm ? vm[1] : rp[2]
742
+ controllerParams.add(name)
743
+ if (!(rp[1] && PROBE8_REQUIRED_FALSE_RE.test(rp[1]))) ep.required.add(name) // ② Spring 缺省必填
744
+ }
745
+ // @PathVariable 参数名进一趟参数集(URL 段非载荷字段,不计端点必填)
746
+ for (const pv of sigText.matchAll(PROBE8_PATH_VARIABLE_RE)) controllerParams.add(pv[1])
747
+ // @RequestBody 类型名收二趟待解析集
748
+ for (const rb of sigText.matchAll(PROBE8_REQUEST_BODY_RE)) ep.bodyTypes.push(rb[1])
749
+ // ③ 方法体前 30 行校验调用三模式 → 端点必填
750
+ if (braceIdx >= 0) {
751
+ const braceLine = text.slice(0, braceIdx).split('\n').length // `{` 所在行(1-based)
752
+ const win = lines.slice(braceLine - 1, braceLine - 1 + 30)
753
+ for (const name of probe8BodyCheckFields(win)) ep.required.add(name)
754
+ }
755
+ endpoints.push(ep)
756
+ fileEndpointCount++
757
+ }
758
+ // prefix 存在但零方法级端点 → 类级通配(无动词可判,method='REQUEST')
759
+ if (prefix && fileEndpointCount === 0) {
760
+ endpoints.push({ path: prefix, method: 'REQUEST', required: new Set(), bodyFields: new Set(), bodyTypes: [] })
761
+ }
762
+ }
763
+
764
+ // ── 二趟:@RequestBody 类型名经全仓回调解析(typeCache 去重同名类型重复解析)──
765
+ const typeCache = new Map()
766
+ for (const ep of endpoints) {
767
+ for (const tn of ep.bodyTypes) {
768
+ if (!typeCache.has(tn)) {
769
+ let c = null
770
+ try { c = resolve(tn) } catch { c = null } // 回调抛错 fail-soft 视为 null
771
+ typeCache.set(tn, typeof c === 'string' ? c : null)
772
+ }
773
+ const content = typeCache.get(tn)
774
+ if (!content) continue // 类型不可解析(null)不炸——bodyFields 留空
775
+ for (const name of probe8PrivateFieldNames(content)) { ep.bodyFields.add(name); entityFields.add(name) }
776
+ // ① 校验注解 5 行窗口必填 → 归引用该类型的端点
777
+ for (const name of probe8NotNullFields(content)) ep.required.add(name)
778
+ }
779
+ }
780
+
781
+ const backendAllFields = new Set([...entityFields, ...controllerParams])
782
+ return {
783
+ entityFields: [...entityFields].sort(),
784
+ endpoints: endpoints.map(e => ({
785
+ path: e.path,
786
+ method: e.method,
787
+ requiredFields: [...e.required].sort(),
788
+ bodyFields: [...e.bodyFields].sort(),
789
+ })),
790
+ backendAllFields: [...backendAllFields].sort(),
791
+ escapeHatchCount,
792
+ nonJavaSkipCount,
793
+ }
794
+ }
795
+
796
+ // 二趟全仓检索的 .java 文件大小上限(Grill F-6——超大生成文件不进类型表)
797
+ const PROBE8_JAVA_FILE_CAP = 500 * 1024
798
+
799
+ /**
800
+ * createTypeResolver——二趟全仓类型解析回调工厂(task-05 调用侧组装)。readdirSync 递归扫
801
+ * srcRoot 下 .java(statSync 大小 cap 500KB,超限跳过),内容首个 class/interface/enum 声明名
802
+ * (缺失退文件名 stem)→ Map<className, content>;同名类先见先留(不覆写不炸)。srcRoot 不可
803
+ * 读/不存在 → 空表闭包(恒返 null,fail-soft)。做 IO 的是本工厂非 extractBackendFields 本体
804
+ * (纯函数约束)。
805
+ * @param {string} srcRoot 扫描根(仓根的 src/ 或 src/main/java 等——由调用侧定)
806
+ * @returns {(typeName: string) => string|null}
807
+ */
808
+ export function createTypeResolver(srcRoot) {
809
+ const typeMap = new Map()
810
+ const walk = (dir) => {
811
+ let entries
812
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
813
+ for (const e of entries) {
814
+ const full = join(dir, e.name)
815
+ try {
816
+ if (e.isDirectory()) { walk(full); continue }
817
+ if (!e.isFile() || !e.name.toLowerCase().endsWith('.java')) continue
818
+ if (statSync(full).size > PROBE8_JAVA_FILE_CAP) continue
819
+ const content = readFileSync(full, 'utf8')
820
+ const decl = content.match(/\b(?:class|interface|enum)\s+([A-Za-z_]\w*)/)
821
+ const name = (decl && decl[1]) || e.name.replace(/\.java$/, '')
822
+ if (!typeMap.has(name)) typeMap.set(name, content)
823
+ } catch { /* 单文件不可读——跳过 */ }
824
+ }
825
+ }
826
+ walk(String(srcRoot || ''))
827
+ return (typeName) => typeMap.get(typeName) || null
828
+ }
829
+
830
+ // ── probe8 direct-compare 对账+渲染(task-04,2026-09-18-probe8-direct-compare / D-002@v1·D-004@v1)──
831
+ // 消费 task-02 前端提取面(fields/fieldLines/urlsByCall)× task-03 后端提取面(endpoints/
832
+ // backendAllFields)做代码级字段直比,两嫌疑面(Grill B-7——backendAllFields 全仓并集防全量假阳):
833
+ // - 漂移嫌疑:前端发送字段 ∉ backendAllFields(Jackson 静默丢弃风险面);
834
+ // - 必填漏发嫌疑:端点 requiredFields ∉ 该端点关联前端文件发送集(EHS 缺发 reportOrgId 同族)。
835
+ // 纯函数零 IO、advisory 档(D-004——不进 errors/warnings,独立渲染子段,命中统计供后续批次
836
+ // 评估升格)。
837
+
838
+ /**
839
+ * comparePayloadFields——前端×后端字段直比对账(task-04,纯函数零 IO)。
840
+ * - 漂移嫌疑:非 escapeHatch 前端文件的每个 field ∉ backendAllFields → driftWarnings 逐条
841
+ * {file, line(fieldLines 首命中行号,缺失 null), field};
842
+ * - 必填漏发嫌疑:端点 requiredFields ∉ 该端点关联前端文件发送集 → missingRequiredWarnings
843
+ * 逐条 {endpoint, method, field, frontendFiles}。关联判定 = 端点 path 与文件 urlsByCall 的
844
+ * normalizedUrl 满足 isSegmentSuffix(段边界后缀,Grill B-8——/orders 命中 /api/v1/orders
845
+ * 不误命中 /rporders;全等分支容忍无 /api 前缀可剥的归一原值如 /rporders)。无 URL 文件归
846
+ * 「未关联端点」面——仅漂移参与、漏发不参与(design §2 口径);端点零关联文件 → 跳过漏发
847
+ * 判定(无发送集可比,「端点无人调用」属覆盖面非载荷面);
848
+ * - escapeHatchCount = 前端面 escapeHatch 文件聚合 + backendStats.escapeHatchCount(后端 hatch
849
+ * 自两提取面透传);nonJavaSkipCount 自 backendStats 透传(后端提取面计数,三主参无承载点
850
+ * → 第四可选参,缺省零)。
851
+ * @param {Array<{filePath: string, extract: {escapeHatch: boolean, fields: string[],
852
+ * fieldLines: Map<string, number>, urlsByCall: Array<{url: string, normalizedUrl: string, line: number}>}}>} frontendByFile
853
+ * 逐前端文件提取面(task-02 产物按文件包装)
854
+ * @param {Array<{path: string, method: string, requiredFields: string[], bodyFields: string[]}>} backendEndpoints
855
+ * 后端端点集(task-03 产物 endpoints)
856
+ * @param {string[]} backendAllFields 后端全字段并集(task-03 产物 backendAllFields)
857
+ * @param {{escapeHatchCount?: number, nonJavaSkipCount?: number}} [backendStats]
858
+ * 后端提取面计数透传(缺省 {escapeHatchCount: 0, nonJavaSkipCount: 0})
859
+ * @returns {{ driftWarnings: Array<{file: string, line: number|null, field: string}>,
860
+ * missingRequiredWarnings: Array<{endpoint: string, method: string, field: string, frontendFiles: string[]}>,
861
+ * escapeHatchCount: number, nonJavaSkipCount: number }}
862
+ */
863
+ export function comparePayloadFields(frontendByFile, backendEndpoints, backendAllFields, backendStats) {
864
+ const feFiles = Array.isArray(frontendByFile) ? frontendByFile : []
865
+ const eps = Array.isArray(backendEndpoints) ? backendEndpoints : []
866
+ const beFields = new Set(Array.isArray(backendAllFields) ? backendAllFields : [])
867
+ const stats = backendStats && typeof backendStats === 'object' ? backendStats : {}
868
+ const statCount = (v) => (Number.isInteger(v) && v > 0 ? v : 0)
869
+
870
+ const driftWarnings = []
871
+ let feEscapeCount = 0
872
+ const urlLinkedFiles = [] // 有 URL 调用的文件 {filePath, fieldSet, urls}——漏发关联面
873
+ for (const fe of feFiles) {
874
+ const filePath = String((fe && fe.filePath) || '')
875
+ const ex = (fe && fe.extract) || {}
876
+ if (ex.escapeHatch) { feEscapeCount++; continue }
877
+ const fields = Array.isArray(ex.fields) ? ex.fields : []
878
+ for (const f of fields) {
879
+ if (!beFields.has(f)) {
880
+ const line = ex.fieldLines instanceof Map ? ex.fieldLines.get(f) : undefined
881
+ driftWarnings.push({ file: filePath, line: typeof line === 'number' ? line : null, field: f })
882
+ }
883
+ }
884
+ const urlsByCall = Array.isArray(ex.urlsByCall) ? ex.urlsByCall : []
885
+ if (urlsByCall.length === 0) continue // 无 URL 文件:未关联端点面——仅上方漂移参与
886
+ urlLinkedFiles.push({
887
+ filePath,
888
+ fieldSet: new Set(fields),
889
+ urls: urlsByCall.map(u => u && u.normalizedUrl).filter(Boolean),
890
+ })
891
+ }
892
+
893
+ const missingRequiredWarnings = []
894
+ for (const ep of eps) {
895
+ const path = String((ep && ep.path) || '')
896
+ const method = String((ep && ep.method) || '')
897
+ const required = Array.isArray(ep && ep.requiredFields) ? ep.requiredFields : []
898
+ if (required.length === 0) continue
899
+ const linked = urlLinkedFiles.filter(f => f.urls.some(u => isSegmentSuffix(u, path)))
900
+ if (linked.length === 0) continue // 零关联文件:无发送集可比,漏发不判(覆盖面归 API 矩阵)
901
+ const sentUnion = new Set()
902
+ for (const f of linked) for (const x of f.fieldSet) sentUnion.add(x)
903
+ for (const field of required) {
904
+ if (!sentUnion.has(field)) {
905
+ missingRequiredWarnings.push({ endpoint: path, method, field, frontendFiles: linked.map(f => f.filePath) })
906
+ }
907
+ }
908
+ }
909
+
910
+ return {
911
+ driftWarnings,
912
+ missingRequiredWarnings,
913
+ escapeHatchCount: feEscapeCount + statCount(stats.escapeHatchCount),
914
+ nonJavaSkipCount: statCount(stats.nonJavaSkipCount),
915
+ }
916
+ }
917
+
918
+ /** direct-compare 子段标题前缀(renderProbe8Lines 尾部子段调用锚) */
919
+ const PROBE8_DIRECT_COMPARE_PREFIX = '- direct-compare:'
920
+
921
+ /**
922
+ * renderDirectCompareSection——direct-compare 子段渲染(task-04,advisory 独立面)。
923
+ * 命中统计行 + 逐条明细行(漂移/漏发各一节);全零态输出「无命中」行。防撞约束(task 卡
924
+ * acceptance):明细行以 `` - ⚠️ ` `` 反引号开头,行首字面与 verify-postcheck.js 的
925
+ * PROBE8_CONTRACT_ORPHANS_LINE_RE / PROBE8_MISSING_REQUIRED_LINE_RE(`- ⚠️ 契约外载荷键 N 条`/
926
+ * `- ⚠️ 契约必填漏发 N 条` 字面前缀)严格不同——渲染行永不进两锚点的 N 求和(负例断言归
927
+ * task-06);PROBE1_HIT_LINE_RE 虽同形但按探针子节定界只统计 s1,本子段落 s8 不进其计数。
928
+ * @param {{ driftWarnings?: Array<{file: string, line: number|null, field: string}>,
929
+ * missingRequiredWarnings?: Array<{endpoint: string, method: string, field: string, frontendFiles: string[]}>,
930
+ * escapeHatchCount?: number, nonJavaSkipCount?: number }} compareResult
931
+ * @returns {string[]} 行数组(不含段标题——拼进探针 8 段尾部)
932
+ */
933
+ export function renderDirectCompareSection(compareResult) {
934
+ const r = compareResult && typeof compareResult === 'object' ? compareResult : {}
935
+ const drift = Array.isArray(r.driftWarnings) ? r.driftWarnings : []
936
+ const missing = Array.isArray(r.missingRequiredWarnings) ? r.missingRequiredWarnings : []
937
+ const esc = r.escapeHatchCount ?? 0
938
+ const nonJava = r.nonJavaSkipCount ?? 0
939
+ const L = []
940
+ if (drift.length === 0 && missing.length === 0) {
941
+ L.push(`${PROBE8_DIRECT_COMPARE_PREFIX} 无命中(漂移 0 / 漏发 0)`)
942
+ // 全零态仍保全跳过面计数(advisory 信息不丢——hatch/非 Java 跳过与命中数是独立维度)
943
+ if (esc > 0 || nonJava > 0) {
944
+ L.push(`- ℹ️ direct-compare: escape hatch ${esc} 文件 / 非 Java 后端跳过 ${nonJava} 文件`)
945
+ }
946
+ return L
947
+ }
948
+ L.push(`${PROBE8_DIRECT_COMPARE_PREFIX} 漂移嫌疑 ${drift.length} 条 / 必填漏发嫌疑 ${missing.length} 条 / escape hatch ${esc} 文件 / 非 Java 后端跳过 ${nonJava} 文件`)
949
+ for (const d of drift) {
950
+ L.push(`- ⚠️ \`${d.file}:${d.line ?? '?'}\` ${d.field} —— 不在后端字段集(漂移嫌疑)`)
951
+ }
952
+ for (const m of missing) {
953
+ L.push(`- ⚠️ \`${m.endpoint} ${m.method} ${m.field}\` —— 后端必填但前端未发送(必填漏发嫌疑,关联面:${(m.frontendFiles || []).join('、') || '?'})`)
954
+ }
955
+ return L
956
+ }
957
+
958
+
192
959
  // ── 探针 8 契约维度(task-01,2026-09-16 跨层契约探针扩展)──
193
960
  // design.md 的契约类章节(接口定义/数据模型等)字段表是「文档契约面」——既有三面(Java 字段/
194
961
  // SQL NOT NULL/前端载荷键)对不上「文档 vs 实现」漂移:契约改了代码没跟上(orphan)、契约必填
@@ -377,12 +1144,20 @@ export function classifyConsumerHints(paths) {
377
1144
  * 探针 8 主体:design 清单三面文件(Java/SQL/前端)→ 双根(主仓 ∪ worktree)∪ 跨仓注册仓根
378
1145
  * 读取 → 归一化比对。advisory:所有输出是「候选」不是结论(UI 本地态键/服务端填充列会自然
379
1146
  * 出现在差异里,agent 逐条复核——口径注记随渲染输出)。
380
- * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string }} args
1147
+ * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string,
1148
+ * diffFiles?: {frontend?: string[], backend?: string[]}|null }} args
1149
+ * diffFiles(task-04 参数化预留,task-05 已接线):显式传入时优先(测试注入口)直接组装
1150
+ * direct-compare;缺省 null → 内部 collectProbe8DiffFiles 三态采集(worktree diff /
1151
+ * in-place diff / design-list 兜底)喂同一组装块,并落模式注记(design-list 态含
1152
+ * 「direct-compare 面可能不全」)与 designOnlyPaths advisory 行(详见函数尾接线块)。
381
1153
  * @returns {{applicable, backendFieldCount, feKeyCount, notNullCount, javaFileCount, sqlFileCount, feFileCount,
382
1154
  * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
383
- * contractCount: number, contractOrphans: Array<{fe, hint?}>, missingRequired: Array<{field, contract}>}}
1155
+ * contractCount: number, contractOrphans: Array<{fe, hint?}>, missingRequired: Array<{field, contract}>,
1156
+ * directCompare?: {driftWarnings: Array<{file, line: number|null, field}>,
1157
+ * missingRequiredWarnings: Array<{endpoint, method, field, frontendFiles: string[]}>,
1158
+ * escapeHatchCount: number, nonJavaSkipCount: number}}}
384
1159
  */
385
- export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName }) {
1160
+ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName, diffFiles = null }) {
386
1161
  const out = {
387
1162
  applicable: false, backendFieldCount: 0, feKeyCount: 0, notNullCount: 0,
388
1163
  javaFileCount: 0, sqlFileCount: 0, feFileCount: 0,
@@ -548,6 +1323,57 @@ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeNam
548
1323
  out.backendFieldCount = backendFields.size
549
1324
  out.feKeyCount = feKeys.size
550
1325
  out.notNullCount = notNullCols.size
1326
+
1327
+ // ── probe8 diff 源接线(task-05,D-001@v1):direct-compare 维度的文件面从 design 清单
1328
+ // 切换为 collectProbe8DiffFiles 三态采集(worktree diff / in-place diff / design-list 兜底),
1329
+ // 分类面(frontend/backend)喂 task-04 组装块;上文 design 清单三面/契约面对账(readEntry
1330
+ // 消费 detailed 的既有逻辑 + contractOrphans/missingRequired)零改动——diff 源只替换
1331
+ // direct-compare 维度文件面,不替换契约面文件面。外部 diffFiles 显式传入时优先(测试注
1332
+ // 入口,跳过采集零注记)。采集器 notes 全量转发(三态模式注记/跨仓失败注记 fail-visible);
1333
+ // design-list 态补「direct-compare 面可能不全」风险注记;designOnlyPaths 非空落 advisory
1334
+ // 行(design 声明但 diff 面无——不参与对账不阻断,供人工复核)。跨仓 diff 面产物是仓根相对
1335
+ // 路径且无 repo 标记,readEntry 按主仓双根读不到即跳过(fail-soft,直比面跨仓缺位是已知
1336
+ // 边界;主仓恰有同名路径时读主仓文件,advisory 面可接受)。──
1337
+ let diffFace = diffFiles
1338
+ if (!diffFace) {
1339
+ const collected = collectProbe8DiffFiles({ cwd, changeName, specBase, repoKeys: [...registry.keys()] })
1340
+ for (const n of collected.notes) out.notes.push(n)
1341
+ if (collected.source === 'design-list') {
1342
+ out.notes.push('文件源=design 清单(diff 不可用)——direct-compare 面可能不全')
1343
+ }
1344
+ if (collected.designOnlyPaths.length > 0) {
1345
+ out.notes.push(`design 声明但 diff 无的路径(advisory,不阻断):${collected.designOnlyPaths.join('、')}`)
1346
+ }
1347
+ diffFace = { frontend: collected.frontend, backend: collected.backend }
1348
+ }
1349
+
1350
+ // ── direct-compare 组装(task-04 参数化,task-05 接线):diffFace(外部注入或内部采集,
1351
+ // collectProbe8DiffFiles 产物形态 {frontend, backend} 路径数组)时组装代码级直比面挂
1352
+ // out.directCompare(渲染经 renderProbe8Lines 尾部子段消费);两数组全缺 → 键恒缺省。
1353
+ // 读取复用 readEntry(主仓∪worktree 双根 + 跨仓注册,fail-visible 注记);二趟类型解析
1354
+ // wtRoot/src→cwd/src 串行 miss-chain(createTypeResolver 各自 fail-soft,零新增 IO 边)。──
1355
+ if (diffFace && (Array.isArray(diffFace.frontend) || Array.isArray(diffFace.backend))) {
1356
+ const frontendByFile = []
1357
+ for (const p of Array.isArray(diffFace.frontend) ? diffFace.frontend : []) {
1358
+ const content = readEntry({ path: p })
1359
+ if (content == null) continue
1360
+ frontendByFile.push({ filePath: p, extract: extractFrontendPayloadFields(p, content) })
1361
+ }
1362
+ const backendFiles = []
1363
+ for (const p of Array.isArray(diffFace.backend) ? diffFace.backend : []) {
1364
+ const content = readEntry({ path: p })
1365
+ if (content == null) continue
1366
+ backendFiles.push({ path: p, content })
1367
+ }
1368
+ const resolvers = [wtRoot, cwd].filter(Boolean).map(b => createTypeResolver(join(b, 'src')))
1369
+ const be = extractBackendFields(backendFiles, (tn) => {
1370
+ for (const r of resolvers) { const c = r(tn); if (c) return c }
1371
+ return null
1372
+ })
1373
+ out.directCompare = comparePayloadFields(frontendByFile, be.endpoints, be.backendAllFields, {
1374
+ escapeHatchCount: be.escapeHatchCount, nonJavaSkipCount: be.nonJavaSkipCount,
1375
+ })
1376
+ }
551
1377
  return out
552
1378
  }
553
1379
 
@@ -820,6 +1646,58 @@ export function runProbe9GuardConsistency({ specBase, cwd, wtRoot = null, change
820
1646
  }
821
1647
 
822
1648
 
1649
+ // ── 探针 10(预填注清零,2026-09-18-artifact-prefill task-03 / FR-03 / D-003@v1 门禁梯度·error 档)──
1650
+ // 背景:三槽预填(src/prefill.js,task-01)的确认动作是「删注」——注在场 = 白名单槽未确认
1651
+ // (预填≠结论)。brainstorm/plan --done 是 advisory(忘删=提示,gates.js 轻档),本探针是梯度
1652
+ // 收口的 error 档:--init 预填进 verify-result.md 探针段(agent 在 verify 阶段看到 ❌ 面),
1653
+ // gates.js verify 收尾复跑本函数同源阻断(verify 完成是 archive 前置——「归档前注清零」的落点)。
1654
+ // 检测复用 task-01 的 hasUnconfirmedPrefill(PREFILL_NOTE 字面包含即未确认);已知误报面:
1655
+ // 散文引用注字面量(如设计文档描述注协议本身)会命中——不追求语义区分,❌ 面向 agent 核对
1656
+ // (真未确认 → 删注;纯散文 → 改写措辞),gate 侧文案同口径。
1657
+ const PROBE10_HEADING = '#### 探针 10:预填注清零(error 门)'
1658
+
1659
+ /**
1660
+ * 探针 10 主体:changeDir 的 design.md + tasks/task-*.md(预填白名单槽宿主文件集——与
1661
+ * gates.js advisory 门 / verify 收尾 error 门同一检测面)逐文件 hasUnconfirmedPrefill。
1662
+ * - applicable:至少一个在检文件存在(变更目录缺失 / 全缺席 → false + 注记,纯骨架零红门禁);
1663
+ * - unclearedFiles:含未删预填注的文件(changeDir 相对、正斜杠,design.md 在前、task 卡按名序);
1664
+ * - 只读不写、无 git 依赖(与 probe8/9 的 design 清单解析面独立——design.md 缺失仍可查
1665
+ * task 卡,反之亦然)。
1666
+ * @param {{ specBase: string, changeName: string }} args
1667
+ * @returns {{applicable: boolean, checkedFiles: number, unclearedFiles: string[], notes: string[]}}
1668
+ */
1669
+ export function runProbe10PrefillNoteClearance({ specBase, changeName }) {
1670
+ const out = { applicable: false, checkedFiles: 0, unclearedFiles: [], notes: [] }
1671
+ if (!specBase || !changeName) {
1672
+ out.notes.push('入参缺失(specBase/changeName)——检测面空')
1673
+ return out
1674
+ }
1675
+ const changeDir = join(specBase, 'changes', changeName)
1676
+ if (!existsSync(changeDir)) {
1677
+ out.notes.push(`变更目录不存在(${changeDir})`)
1678
+ return out
1679
+ }
1680
+ const candidates = [join(changeDir, 'design.md')]
1681
+ try {
1682
+ const tasksDir = join(changeDir, 'tasks')
1683
+ if (existsSync(tasksDir)) {
1684
+ candidates.push(...readdirSync(tasksDir).filter(n => /^task-.+\.md$/.test(n)).sort().map(n => join(tasksDir, n)))
1685
+ }
1686
+ } catch { /* tasks 目录不可读 → 只检 design.md */ }
1687
+ for (const p of candidates) {
1688
+ if (!existsSync(p)) continue
1689
+ out.checkedFiles++
1690
+ if (hasUnconfirmedPrefill(p)) out.unclearedFiles.push(relative(changeDir, p).split('\\').join('/'))
1691
+ }
1692
+ if (out.checkedFiles === 0) {
1693
+ out.notes.push('无在检文件(design.md 与 tasks/task-*.md 均缺席——纯骨架/中间态)')
1694
+ return out
1695
+ }
1696
+ out.applicable = true
1697
+ return out
1698
+ }
1699
+
1700
+
823
1701
  const PROBE7_HEADING = '#### 探针 7:验收×测试覆盖矩阵'
824
1702
  // execute run id 格式(与 task-review.js isValidExecuteRunId 同口径锚定,防提示词注入/路径穿越)
825
1703
  const PROBE7_EXEC_RUN_ID_RE = /^exec-\d{4}-\d{2}-\d{2}-\d{6}(?:-[a-z0-9]{1,8}){0,2}$/
@@ -1048,8 +1926,8 @@ function renderProbe7Lines(p7) {
1048
1926
  L.push('- 不适用(无 TaskCard)')
1049
1927
  return L
1050
1928
  }
1051
- 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(文档/部署类显式逃生门)。关键词命中只是提示,命中≠判定。 -->')
1052
- 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 纯值,备注写在证据列)。 -->')
1929
+ L.push('<!-- 口径注记:探针 3 = 模块目录递归存在性面(allowed_paths 目录附近有没有测试);探针 7 = allowed_paths ∪ review changedFiles ∪ 直接下游卡测试 结构归属承接面(每条 acceptance 由哪些测试承接;下游消费卡的测试可承接上游 provider 的 acceptance——probe7-provider-tests-in-consumer-card);两者并排冲突以 7 为准。判定枚举(五选一):covered / covered-service / partial / uncovered / non-testable(covered-service 适用:端点行为由 service 层等非端点层测试锁定,证据附测试锚点;non-testable 是文档/部署类显式逃生门)。关键词命中只是提示,命中≠判定。 -->')
1930
+ L.push('<!-- 预填说明(ql-20260915-004):判定列为 CLI 机械预填,agent 逐格复核改写——规则:无归属→uncovered(文档/部署/doc/deploy/manual/config 类词→non-testable);有归属且命中≥1→covered;有归属零命中→partial。covered/covered-service/partial 证据须含测试锚点三形态之一(file:line / `.test.` 测试文件名 / 反引号包裹的路径或测试名),行号可省;uncovered/non-testable 证据自由形态。预填≠结论:与事实不符的格子必须改写(枚举须保持 covered/covered-service/partial/uncovered/non-testable 纯值,备注写在证据列)。 -->')
1053
1931
  // 零自动化承接计数(坑 review-zero-coverage-unwalked,2026-09-16 EHS 二次复核实证:execute
1054
1932
  // 期 review 走查面事实上跟着测试覆盖走——「新增-部门支线测试充分,编辑路径与相关方支线
1055
1933
  // 零覆盖」恰是 5 个 P1 的藏身处。矩阵渲染时顺带计数零承接条目,verify 复核强制显式走查
@@ -1341,6 +2219,16 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
1341
2219
  probe9.notes = [`探针 9 执行失败(fail-soft 跳过):${e && e.message ? e.message : e}`]
1342
2220
  }
1343
2221
 
2222
+ // ── 探针 10:预填注清零(error 门;2026-09-18-artifact-prefill task-03——门禁梯度 error 档
2223
+ // 的检测单点:--init 预填本段 + gates.js verify 收尾 error 门 / brainstorm·plan advisory 复跑
2224
+ // 同一实现,不二算。fail-soft 同探针 8/9:异常降级 not-applicable 注记不炸整体。──
2225
+ let probe10 = { applicable: false, checkedFiles: 0, unclearedFiles: [], notes: [] }
2226
+ try {
2227
+ probe10 = runProbe10PrefillNoteClearance({ specBase, changeName })
2228
+ } catch (e) {
2229
+ probe10.notes = [`探针 10 执行失败(fail-soft 跳过):${e && e.message ? e.message : e}`]
2230
+ }
2231
+
1344
2232
  // ── 接口面 + 消费端归类(task-04 / FR-05 / D-005~D-007):design.md 接口段 tolerant
1345
2233
  // 解析 + 清单消费端归类——骨架「## 接口验证覆盖矩阵」段预填与 facts 落盘
1346
2234
  // (backfillFactsFromMdAndTests)共用同一解析器(单一产物源,不各读各的)。fail-soft:
@@ -1354,7 +2242,7 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
1354
2242
  }
1355
2243
  } catch { /* design 读/解析异常 → 空面(fail-soft) */ }
1356
2244
 
1357
- return { probe1, probe3, probe5, probe6, probe7, probe8, probe9, apiFace, consumerHints }
2245
+ return { probe1, probe3, probe5, probe6, probe7, probe8, probe9, probe10, apiFace, consumerHints }
1358
2246
  }
1359
2247
 
1360
2248
  /**
@@ -1461,6 +2349,9 @@ export function renderVerifyProbesReport(result) {
1461
2349
  // 探针 9 紧随探针 8;旧 result 无 probe9 键(存量调用方/合成 result)→ applicable=false
1462
2350
  // 渲染「不适用」行 + notes,零回归(探针 7 兜底口径同款)。
1463
2351
  L.push(...(renderProbe9Lines(result.probe9 || { applicable: false, javaFileCount: 0, groupCount: 0, inconsistentGroups: [], notes: [] })))
2352
+ // 探针 10 紧随探针 9;旧 result 无 probe10 键(存量调用方/合成 result)→ applicable=false
2353
+ // 渲染「不适用」行 + notes,零回归(探针 8/9 兜底口径同款)。
2354
+ L.push(...(renderProbe10Lines(result.probe10 || { applicable: false, checkedFiles: 0, unclearedFiles: [], notes: [] })))
1464
2355
  return L.join('\n')
1465
2356
  }
1466
2357
 
@@ -1469,7 +2360,12 @@ export function renderVerifyProbesReport(result) {
1469
2360
  * @param {{applicable: boolean, backendFieldCount?: number, feKeyCount?: number, notNullCount?: number,
1470
2361
  * javaFileCount?: number, sqlFileCount?: number, feFileCount?: number,
1471
2362
  * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
1472
- * contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}>}} p8
2363
+ * contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}>,
2364
+ * directCompare?: {driftWarnings: Array<{file, line: number|null, field}>,
2365
+ * missingRequiredWarnings: Array<{endpoint, method, field, frontendFiles: string[]}>,
2366
+ * escapeHatchCount: number, nonJavaSkipCount: number}}} p8
2367
+ * directCompare(task-04):runProbe8PayloadParity 在外部传入 diffFiles 时组装的代码级直比
2368
+ * 面——缺失(存量调用/兜底形态)时子段 if 块静默跳过,零回归。
1473
2369
  * @returns {string[]} 行数组(含段标题)
1474
2370
  */
1475
2371
  function renderProbe8Lines(p8) {
@@ -1503,6 +2399,10 @@ function renderProbe8Lines(p8) {
1503
2399
  L.push('- ✅ 载荷字段面零疑似差异(归一化覆盖 + NOT NULL 全见)')
1504
2400
  }
1505
2401
  for (const n of p8.notes || []) L.push(`- ℹ️ ${n}`)
2402
+ // direct-compare 子段(task-04):代码级直比面(漂移嫌疑/必填漏发嫌疑,advisory 独立面——
2403
+ // 不进 errors/warnings)。独立 if 块,删除即整体回退;p8.directCompare 缺失(存量调用/兜底
2404
+ // 形态)时静默跳过零回归。明细行防撞 verify-postcheck PROBE8 锚点(行首字面前缀不同)。
2405
+ if (p8.directCompare) L.push(...renderDirectCompareSection(p8.directCompare))
1506
2406
  return L
1507
2407
  }
1508
2408
 
@@ -1538,6 +2438,31 @@ function renderProbe9Lines(p9) {
1538
2438
  return L
1539
2439
  }
1540
2440
 
2441
+ /**
2442
+ * 渲染探针 10 段(renderProbe9Lines 同构;error 门口径注记随段输出——❌ 面是 verify 完成
2443
+ * 前须清零的死信,不是 advisory 复核面)。汇总行「预填注未清 N 处」的 N 为后续一致性抽查
2444
+ * 锚点扩展留位(verify-postcheck 锚点面归后续任务,本卡只出 producer 侧——probe9 先例同款)。
2445
+ * @param {{applicable: boolean, checkedFiles?: number, unclearedFiles: string[], notes: string[]}} p10
2446
+ * @returns {string[]} 行数组(含段标题)
2447
+ */
2448
+ function renderProbe10Lines(p10) {
2449
+ const L = [PROBE10_HEADING]
2450
+ if (!p10 || !p10.applicable) {
2451
+ L.push('- 不适用(design.md 与 tasks/task-*.md 均缺席——纯骨架/中间态,预填注无从在场)')
2452
+ for (const n of (p10 && p10.notes) || []) L.push(`- ℹ️ ${n}`)
2453
+ return L
2454
+ }
2455
+ L.push('<!-- 口径注记:预填注(来源注协议)在场 = 白名单槽未确认(预填≠结论);删注 = 确认动作。本探针是门禁梯度 error 档——verify --done 时 gate 复跑同源检测,注未清零阻断完成(归档前清零兜底)。已知误报面:散文引用注字面量会命中(如文档描述注协议本身)——核对后真未确认则删注,纯散文则改写措辞,不得删探针段。 -->')
2456
+ if ((p10.unclearedFiles || []).length > 0) {
2457
+ L.push(`- ❌ 预填注未清 ${p10.unclearedFiles.length} 处(error 门——verify 完成前须逐槽核对后删注):`)
2458
+ for (const f of p10.unclearedFiles) L.push(`- ❌ \`${mdEscapeCell(f, 120)}\` 仍含未删预填注`)
2459
+ } else {
2460
+ L.push(`- ✅ 预填注清零(${p10.checkedFiles ?? 0} 个在检文件无未确认预填)`)
2461
+ }
2462
+ for (const n of p10.notes || []) L.push(`- ℹ️ ${n}`)
2463
+ return L
2464
+ }
2465
+
1541
2466
  // ── 接口验证覆盖矩阵段渲染(task-04 / FR-05 / D-005~D-007)──
1542
2467
  // 骨架新写路径(generateVerifyResultSkeleton)与缺段补齐(ensureApiCoverageMatrixSection)
1543
2468
  // 共用单一实现——renderProbe7Lines 先例。不动 probe7 既有矩阵(独立 ## 章节并行存在,口径
@@ -1549,7 +2474,7 @@ const API_MATRIX_WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH'])
1549
2474
 
1550
2475
  /**
1551
2476
  * 渲染「## 接口验证覆盖矩阵」段(task-04)。
1552
- * 三态:解析有端点 → 逐端点预填行(判定列 `<待填:四选一>` 占位,同 probe7 预填口径供 agent
2477
+ * 三态:解析有端点 → 逐端点预填行(判定列 `<待填:五选一>` 占位,同 probe7 预填口径供 agent
1553
2478
  * 复核);解析零行有声明 → 声明占位行「本变更接口面:<N> 端点(agent 声明)」(D-005 零解析
1554
2479
  * 降级,对账分母=声明数);零解析零声明 → 「无接口面」注记行(非判级 critical 变更零行为
1555
2480
  * 注记即可——判级 critical 的 error 拦截归 task-05 validator)。段尾 advisory 占位注记
@@ -1562,7 +2487,7 @@ function renderApiCoverageMatrixLines(apiFace) {
1562
2487
  const endpoints = face && Array.isArray(face.endpoints) ? face.endpoints : []
1563
2488
  const declared = face && typeof face.declared === 'number' ? face.declared : null
1564
2489
  const L = [`${API_MATRIX_HEADING} [层:人工判断——CLI 预填复核]`]
1565
- L.push('<!-- 口径注记(与探针 7 互指,R-07):探针 7 = 验收项 × 测试承接面(每条 acceptance 由哪些测试承接);本矩阵 = 接口端点 × 验证用例面(design 接口段每个端点由哪些验证用例/冒烟步骤覆盖)——两者并排互补,双矩阵并行存在。端点集来自 design.md 接口段 tolerant 解析(parseDesignApiTable:段头宽收 + 方法/路径双条件),预填≠结论,agent 逐行复核。判定枚举(四选一):covered / partial / uncovered / non-testable。 -->')
2490
+ L.push('<!-- 口径注记(与探针 7 互指,R-07):探针 7 = 验收项 × 测试承接面(每条 acceptance 由哪些测试承接);本矩阵 = 接口端点 × 验证用例面(design 接口段每个端点由哪些验证用例/冒烟步骤覆盖)——两者并排互补,双矩阵并行存在。端点集来自 design.md 接口段 tolerant 解析(parseDesignApiTable:段头宽收 + 方法/路径双条件),预填≠结论,agent 逐行复核。判定枚举(五选一):covered / covered-service / partial / uncovered / non-testable——covered-service 适用:端点行为由 service 层等非端点层测试锁定;证据须含测试文件锚点三形态之一(`.test.` / file:line / 反引号包裹的路径或测试名)。 -->')
1566
2491
  L.push('<!-- 预填说明:端点行由 CLI 机械预填,判定/用例依据 ID/结果/证据由 agent 逐格填写——用例依据 ID 锚点五形态:design接口表#METHOD /path、权限矩阵[角色×动作]、契约表@行标识、DDL@列名、载荷@构造点路径(须真实命中对应表/段,防空指)。 -->')
1567
2492
  L.push('<!-- 文法注释:子行 = 端点行下一行、两空格缩进、以「↳ <消费端>:」前缀书写(消费端细分承接面,不计矩阵行账);探索行 = 判定 uncovered 且证据列含 [探索] 标记(探索性验证不算覆盖)。 -->')
1568
2493
  if (endpoints.length === 0 && declared === null) {
@@ -1576,10 +2501,10 @@ function renderApiCoverageMatrixLines(apiFace) {
1576
2501
  L.push('|---|---|---|---|---|')
1577
2502
  if (endpoints.length > 0) {
1578
2503
  for (const ep of endpoints) {
1579
- L.push(`| ${mdEscapeCell(`${ep.method} ${ep.path}`, 160)} | <待填:四选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
2504
+ L.push(`| ${mdEscapeCell(`${ep.method} ${ep.path}`, 160)} | <待填:五选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
1580
2505
  }
1581
2506
  } else {
1582
- L.push(`| 本变更接口面:${declared} 端点(agent 声明) | <待填:四选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
2507
+ L.push(`| 本变更接口面:${declared} 端点(agent 声明) | <待填:五选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
1583
2508
  L.push('<!-- 解析零行降级(D-005):接口面以 agent 声明为准(对账分母=声明数);声明与实际不符时补 design 接口段表格后重跑 --init 刷新本段 -->')
1584
2509
  }
1585
2510
  L.push('<!-- advisory 尾注(warning 计算归 validator,本段只留位):有消费端未填子行的端点将列于此(advisory——消费端归类=design 清单启发式,数据面 facts.consumerHints);写端点(POST/PUT/DELETE/PATCH)未在权限矩阵段声明的将列于此(advisory——补行或显式豁免「无权限约束」,数据面 facts.apiFace.writeEndpoints;表缺行会让派生框架继承你的洞) -->')
@@ -1606,6 +2531,7 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
1606
2531
  const p6 = (result && result.probe6) || {}
1607
2532
  const p8 = (result && result.probe8) || {}
1608
2533
  const p9 = (result && result.probe9) || {}
2534
+ const p10 = (result && result.probe10) || {}
1609
2535
  // v2(2026-09-08-ir-verify-facts):probes 机器段原样;conclusion/tests/requiredEvidence/
1610
2536
  // runtimeEvidence/factsConsistency 五段是 slot-backfill/实测回填段(D-001@v2),--init 快照
1611
2537
  // 不落键(writeVerifyFacts 分段合并时保留既有固化段),由 backfillFactsFromMdAndTests 填。
@@ -1665,6 +2591,13 @@ export function buildVerifyFacts(result, { changeName, now } = {}) {
1665
2591
  inconsistentGroups: len(p9.inconsistentGroups),
1666
2592
  }),
1667
2593
  },
2594
+ probe10: {
2595
+ command,
2596
+ metrics: defined({
2597
+ checkedFiles: num(p10.checkedFiles),
2598
+ unclearedFiles: len(p10.unclearedFiles),
2599
+ }),
2600
+ },
1668
2601
  },
1669
2602
  }
1670
2603
  }
@@ -1748,7 +2681,7 @@ const HANDOVER_DEFAULT_SEVERITY = (type) =>
1748
2681
  // 降级理由文法(X-02):(降级:<理由>,依据 <file:line 或 D-xxx>)——全角括号/冒号/逗号,
1749
2682
  // 依据锚点 D-xxx 或 file:line。JS 正则不转义全角括号(知识库坑)。blocking→advisory 降级
1750
2683
  // 未命中本文法 → 仍按 blocking(fail-closed,防「全标 advisory」钻空——D-005@v2)。
1751
- const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+|[^\s,):]+:\d+))/
2684
+ const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+(?:@\d+)?|[^\s,):]+:\d+))/
1752
2685
  // severity 解析:显式 blocking 优先(混写保守);显式 advisory 对 blocking 缺省类型构成降级,
1753
2686
  // 理由文法在第 4 列或条件列任一命中即认(骨架第 4 列定型前 producer 宽收两侧;抽查面归
1754
2687
  // checkProbeConsistency 消费侧后续任务);两枚举都不含 → 缺省映射。
@@ -1778,6 +2711,8 @@ export function parseHandoverRows(md) {
1778
2711
  const condition = row[3].trim()
1779
2712
  if (type === '类型' || /^-{2,}$/.test(type.replace(/\|/g, ''))) continue // 表头/分隔行
1780
2713
  if (type.startsWith('<') || item.startsWith('<')) continue // 骨架占位行
2714
+ if (type === '无' || item === '无') continue // 裸「无」行=骨架指引的空表占位(结论=PASS/FAIL 写「无」)——
2715
+ // 真「无可移交」的合法表达是矩阵干净+零有效行,不是一行无;精确相等防误杀「无明确去向」类真条目
1781
2716
  if (!type || !item) continue
1782
2717
  const normalizedType = type.toLowerCase().replace(/[\s_]+/g, '-')
1783
2718
  items.push({
@@ -1839,17 +2774,10 @@ export function parseDbScriptDeclarations(md) {
1839
2774
  // 正则下全判 build 会误拦金路径,且脚本形态词表不可枚举(RECEIPT_CROSS_LAYER_RE 正则族
1840
2775
  // 零词变动)。对齐目标:change-risk-profile.js classifyReceiptSourceTag(sourceMark 参数与
1841
2776
  // CLI_SMOKE_SOURCE_MARK 常量)——**任一侧增改标记族必须双侧同步**(G-3 铁律)。
1842
- const RECEIPT_CROSS_LAYER_RE = new RegExp([
1843
- '\\bcurl\\b', '\\bwget\\b', '\\bhttpie\\b', '\\bInvoke-WebRequest\\b', '\\bInvoke-RestMethod\\b',
1844
- '\\biwr\\b', '\\birm\\b', 'https?://',
1845
- 'spring-boot:run', '\\bjava\\b[^|\\n]*\\s-jar\\b',
1846
- '\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?(?:dev|start|serve|smoke)\\b',
1847
- '\\bdotnet\\s+run\\b', '\\bflask\\s+run\\b', '\\buvicorn\\b', '\\bgunicorn\\b',
1848
- '\\b(?:nc|netcat|telnet|socat)\\b',
1849
- ].join('|'), 'i')
1850
- 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
2777
+ // (正则族单点收敛 ql-20260918-001:RECEIPT_CROSS_LAYER_RE/SMOKE_RECEIPT_SOURCE_MARK
2778
+ // 从 change-risk-profile.js 导入(RECEIPT_SOURCE_CROSS_LAYER_RE/CLI_SMOKE_SOURCE_MARK 别名)——
2779
+ // 消灭双文件逐词同步的口径漂移面(G-3 根因)。
1851
2780
  /** CLI 机器段来源标记(与 change-risk-profile.js CLI_SMOKE_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
1852
- const SMOKE_RECEIPT_SOURCE_MARK = 'cli-noai-smoke'
1853
2781
  function classifyReceiptCommandSource(command, sourceMark) {
1854
2782
  if (sourceMark === SMOKE_RECEIPT_SOURCE_MARK) return 'cross-layer'
1855
2783
  const cmd = String(command || '')
@@ -1977,14 +2905,15 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
1977
2905
  if (slots.hasReceiptSlot && slots.runtimeEvidence.length > 0) {
1978
2906
  facts.runtimeEvidence = slots.runtimeEvidence
1979
2907
  }
1980
- // 移交项结构化回填(handover-only-in-prose):有有效行 → facts.handover 落盘(机器可读);
2908
+ // 移交项结构化回填(handover-only-in-prose):facts.handover **恒落盘**(零行= {count:0, items:[]}——
2909
+ // 2026-09-18-fr-index-l1 verify 期实证死锁修复:原「有行才写」使 零移交+结论 PASS 被
2910
+ // evaluatePassEligibility 条件② handoverFieldMissing fail-closed 拦死(PASS+真零移交结构性不可能);
2911
+ // eligibility 测试的 CLEAN_FACTS 本就是 count:0 形态——字段在场零行=净,本行只是对齐既有契约);
1981
2912
  // PASS WITH NOTES 零有效行 → advisory 警告(不阻断——存量 PASS WITH NOTES 无此章节是常态,
1982
2913
  // 渐进采纳)。EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1,清单化才有
1983
2914
  // 「谁兜、怎么复跑」的可追溯面。
1984
2915
  const handoverItems = parseHandoverRows(verifyMdText || '')
1985
- if (handoverItems.length > 0) {
1986
- facts.handover = { count: handoverItems.length, items: handoverItems }
1987
- }
2916
+ facts.handover = { count: handoverItems.length, items: handoverItems }
1988
2917
  if (conclusionSlot === 'PASS WITH NOTES' && handoverItems.length === 0) {
1989
2918
  console.warn('⚠️ 结论=PASS WITH NOTES 但「移交项(结构化)」章节零有效行——正文叙述的移交项(环境阻断复跑条件/人工验收步骤/待执行脚本)请结构化进 ## 移交项(结构化) 表格(类型枚举 env-blocked/manual-acceptance/db-script/other),避免移交项只活在 prose 里没人兜(2026-09-15 EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1)。')
1990
2919
  }
@@ -2412,10 +3341,13 @@ export function buildDecisionChainMatrix(changeDir) {
2412
3341
  const id = (raw.match(/^id:\s*(\S+)/m) || [])[1] || f.replace(/\.md$/, '')
2413
3342
  const decLine = (raw.match(/^decision_ids:\s*\[([^\]]*)\]/m) || [])[1] || ''
2414
3343
  const reqLine = (raw.match(/^requirement_ids:\s*\[([^\]]*)\]/m) || [])[1] || ''
3344
+ // token 剥首尾成对引号:流式数组的引号写法(['D-001@v1'])是合法 YAML 且 agent Edit 填值
3345
+ // 主流形态,只 trim 不剥会让引号卡与 decisions.md 裸 id 永不相等(矩阵静默全量 ⚠️)
3346
+ const stripQuotes = (s) => s.trim().replace(/^['"](.*)['"]$/, '$1').trim()
2415
3347
  taskRefs.push({
2416
3348
  task: id,
2417
- decisions: new Set(decLine.split(',').map((s) => s.trim()).filter(Boolean)),
2418
- reqs: new Set(reqLine.split(',').map((s) => s.trim()).filter(Boolean)),
3349
+ decisions: new Set(decLine.split(',').map(stripQuotes).filter(Boolean)),
3350
+ reqs: new Set(reqLine.split(',').map(stripQuotes).filter(Boolean)),
2419
3351
  })
2420
3352
  }
2421
3353
  }
@@ -2481,6 +3413,8 @@ export function generateVerifyResultSkeleton(result) {
2481
3413
  const L = [
2482
3414
  '# 验证报告(骨架由 `sillyspec verify-probes --change <变更名> --init` 生成)',
2483
3415
  '',
3416
+ '> 引用规范:矩阵证据/测试结果等处的源码位置写仓根相对全路径+行号(src/foo.js:123)——裸文件名在 docs-check 层1 靠 basename 全仓扫描找候选,找不到候选或关键词窗口不匹配即失效,到 pre-push 才拦(2026-09-19 实证 64 处返工)。',
3417
+ '',
2484
3418
  '> 探针结果已机械预填;其余章节把 `<!--TODO-->` 替换为真实内容。**结论只认「结论枚举:」槽行**——', /* probe1-noqa */
2485
3419
  '> 槽行留「<待填:三选一>」会被 gate 判不过(fail-closed),正文其他位置的 PASS/FAIL 字样不参与判定。',
2486
3420
  '>',