sillyspec 3.28.11 → 3.28.13

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.
@@ -24,7 +24,7 @@
24
24
  import { existsSync, readFileSync, readdirSync, statSync, writeFileSync } from 'fs'
25
25
  import { join, dirname, basename, resolve, isAbsolute } from 'path'
26
26
  import jsYaml from 'js-yaml'
27
- import { gitQuiet } from './git-helper.js'
27
+ import { gitQuiet, unquoteGitPath } from './git-helper.js'
28
28
  import {
29
29
  FACTS_SCHEMA_VERSION, EVIDENCE_SLOT_HEADING, RECEIPT_SLOT_HEADING, parseEvidenceSlots,
30
30
  } from './verify-facts-schema.js'
@@ -32,6 +32,7 @@ import { parseFileChangeListDetailed } from './change-list.js'
32
32
  import { parseDecisions } from './decision-distill.js'
33
33
  import { parseAllowedPaths, parseRepo, parseRepoRegistry } from './stages/plan-postcheck.js'
34
34
  import { verifyApiParity, _readWorktreeMeta } from './contract-matrix.js'
35
+ 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
36
  import { splitOwnVsForeignDiffFiles } from './foreign-declared.js'
36
37
  import { resolveSpecDir, resolveRuntimeRoot, detectWorktreeSpecDrift } from './run/shared.js'
37
38
 
@@ -189,6 +190,768 @@ export function extractPayloadKeys(text) {
189
190
  return keys
190
191
  }
191
192
 
193
+ // ── probe8 diff 源采集器(task-01,2026-09-18-probe8-direct-compare / D-001@v1:文件源从
194
+ // design 清单声明面切到 diff 实际面——三态 fallback 链 + 文件分类 + design 差集 advisory)──
195
+ // 文件分类规则(Grill B-10):.java→backend;.js/.jsx/.wxml/.vue→frontend(.vue 无条件
196
+ // frontend,N-2);.ts/.tsx 按目录启发式裁决——目录段词(驼峰拆词,只看目录部分、文件名不
197
+ // 参与——classifyConsumerHints 段级匹配同哲学,'homepages.js' 文件名不命中 pages 段)含
198
+ // routes/pages/components/models→frontend;含 controller/service/mapper/entity/dto/api(含
199
+ // 常见复数形)→backend;目录两不中再看文件头 10 行含 @RequestMapping/@RestController/@Service
200
+ // →backend;仍两不中→other。.css/.less/.json/.md/.sql 等→other。
201
+ const PROBE8_FE_DIR_WORDS = new Set(['routes', 'pages', 'components', 'models'])
202
+ const PROBE8_BE_DIR_WORDS = new Set(['controller', 'controllers', 'service', 'services', 'mapper', 'mappers', 'entity', 'entities', 'dto', 'dtos', 'api', 'apis'])
203
+ const PROBE8_BE_HEAD_RE = /@(?:RequestMapping|RestController|Service)\b/
204
+
205
+ /** 目录段词化(驼峰拆词 + 非字母数字切段;只取目录部分,根级文件无目录词) */
206
+ function probe8DirWords(p) {
207
+ const posix = String(p || '').split('\\').join('/')
208
+ const cut = posix.lastIndexOf('/')
209
+ return (cut === -1 ? '' : posix.slice(0, cut))
210
+ .replace(/([a-z0-9])([A-Z])/g, '$1 $2')
211
+ .toLowerCase()
212
+ .split(/[^a-z0-9]+/)
213
+ .filter(Boolean)
214
+ }
215
+
216
+ /**
217
+ * probe8 文件分类(task-01,纯函数——采集器内部消费;task-02/03 经 collectProbe8DiffFiles 的
218
+ * fileClassification 字段消费,不直接引用本函数)。
219
+ * @param {string} filePath 仓根相对路径(反斜杠容忍)
220
+ * @param {string|null} [headText] 文件头文本(调用方传头 10 行;.ts/.tsx 目录启发式未裁决时消费)
221
+ * @returns {'frontend'|'backend'|'other'}
222
+ */
223
+ function classifyProbe8FilePath(filePath, headText = null) {
224
+ const p = String(filePath || '').split('\\').join('/')
225
+ const lower = p.toLowerCase()
226
+ if (lower.endsWith('.java')) return 'backend'
227
+ if (/\.(js|jsx|wxml|vue)$/.test(lower)) return 'frontend'
228
+ if (/\.(ts|tsx)$/.test(lower)) {
229
+ const words = probe8DirWords(p)
230
+ if (words.some(w => PROBE8_FE_DIR_WORDS.has(w))) return 'frontend'
231
+ if (words.some(w => PROBE8_BE_DIR_WORDS.has(w))) return 'backend'
232
+ if (headText && PROBE8_BE_HEAD_RE.test(headText)) return 'backend'
233
+ }
234
+ return 'other'
235
+ }
236
+
237
+ /**
238
+ * collectProbe8DiffFiles——probe8 文件源 diff 实际面采集器(task-01,D-001@v1)。三态 fallback 链
239
+ * (各级 fail-open 不抛):
240
+ * ① diff——worktree 可用(_readWorktreeMeta 命中且 gitDir≠主仓 cwd;in-place-fallback/仓
241
+ * 已删的 meta 落回 ②):gitQuiet 数组参数取 diff baseline..HEAD ∪ status --porcelain
242
+ * (-uall 防目录折叠,同构先例 _resolveDiffFilesForParity,contract-matrix.js);
243
+ * ② in-place——worktree 缺失:主仓 diff HEAD~1..HEAD ∪ porcelain(含已提交窗口,Grill B-9);
244
+ * ③ design-list——主仓两路 git 取数全失败:退 design 清单声明面(parseFileChangeListDetailed)
245
+ * + 模式注记「文件源=design 清单(diff 不可用)」(fail-open 不抛)。
246
+ * 跨仓双源直采(①②态启用,不 import run/ 目录):parseRepoRegistry 读 local.yaml repos →
247
+ * 每注册仓根 git diff HEAD~1..HEAD ∪ porcelain(复刻 collectCrossRepoDiffRoots 只读口径,
248
+ * run/complete.js 同款);仓根不存在/取数失败跳过不炸;产物仓根相对路径。
249
+ * design 差集(advisory,不参与对账不阻断):design 清单有但 diff 面无的路径收 designOnlyPaths
250
+ * (跨仓条目带 cross-repo:<key>: 前缀消歧);design-list 态文件源即清单面 → 差集恒空。
251
+ * 产物路径一律 unquoteGitPath + 正斜杠归一(quotepath=false 下仍防控制字符引号形态)。
252
+ * @param {{ cwd: string, changeName: string, specBase: string, repoKeys?: string[]|null }} args
253
+ * @returns {{ source: 'diff'|'in-place'|'design-list', fallbackMode: 'diff'|'in-place'|'design-list',
254
+ * diffFiles: string[], fileClassification: {frontend: string[], backend: string[], other: string[]},
255
+ * frontend: string[], backend: string[], other: string[], designOnlyPaths: string[], notes: string[] }}
256
+ */
257
+ export function collectProbe8DiffFiles({ cwd, changeName, specBase, repoKeys = null }) {
258
+ const out = {
259
+ source: 'design-list', fallbackMode: 'design-list', diffFiles: [],
260
+ fileClassification: { frontend: [], backend: [], other: [] },
261
+ frontend: [], backend: [], other: [], designOnlyPaths: [], notes: [],
262
+ }
263
+ if (!cwd || !specBase || !changeName) {
264
+ out.notes.push('collectProbe8DiffFiles 入参缺失(cwd/specBase/changeName)——文件源空面')
265
+ return out
266
+ }
267
+
268
+ // 路径归一:剥外层引号 → unquoteGitPath 解码转义(八进制/\"/\\)→ 正斜杠归一
269
+ const normGitPath = (raw) => unquoteGitPath(String(raw || '').trim().replace(/^"|"$/g, '')).split('\\').join('/')
270
+ // porcelain 行:XY <path>(前 3 字符状态码,trim:false 保前导空格;重命名 old -> new 取 new)
271
+ // ——口径同 _resolveDiffFilesForParity(contract-matrix.js)
272
+ const fromPorcelain = (stOut) => {
273
+ const files = []
274
+ for (const line of String(stOut || '').split('\n')) {
275
+ if (!line || line.length < 4) continue
276
+ const p = line.slice(3).trim().split(' -> ').pop() || ''
277
+ if (p) files.push(normGitPath(p))
278
+ }
279
+ return files
280
+ }
281
+ // git 双源采集:diff --name-only ∪ status --porcelain -uall;两取数任一成功即 ok(全失败
282
+ // 返 null 交 fallback 链降级);产物 Set 去重
283
+ const gitFace = (gitDir, diffSpec) => {
284
+ let ok = false
285
+ const files = new Set()
286
+ const d = gitQuiet(gitDir, ['diff', '--name-only', diffSpec], { timeout: 30000 })
287
+ if (d !== null) {
288
+ ok = true
289
+ for (const line of String(d).split('\n')) {
290
+ const f = normGitPath(line)
291
+ if (f) files.add(f)
292
+ }
293
+ }
294
+ const st = gitQuiet(gitDir, ['status', '--porcelain', '--untracked-files=all'], { timeout: 30000, trim: false })
295
+ if (st !== null) {
296
+ ok = true
297
+ for (const f of fromPorcelain(st)) files.add(f)
298
+ }
299
+ return ok ? [...files] : null
300
+ }
301
+
302
+ // ── 主仓三态 fallback ──
303
+ let source = null
304
+ let mainRoot = cwd
305
+ const mainFiles = []
306
+ const wt = _readWorktreeMeta(specBase, cwd, changeName)
307
+ if (wt && resolve(wt.gitDir) !== resolve(cwd)) {
308
+ // baseline 口径同 _resolveDiffFilesForParity:baselineCommit / actualBaseHash / baseHash
309
+ const diffBase = wt.meta.baselineCommit || wt.meta.actualBaseHash || wt.meta.baseHash
310
+ if (diffBase) {
311
+ const face = gitFace(wt.gitDir, `${diffBase}..HEAD`)
312
+ if (face) {
313
+ source = 'diff'
314
+ mainRoot = wt.gitDir
315
+ mainFiles.push(...face)
316
+ out.notes.push(`文件源=worktree diff(${diffBase.slice(0, 8)}..HEAD ∪ porcelain)`)
317
+ }
318
+ }
319
+ }
320
+ if (!source) {
321
+ // in-place(Grill B-9 含已提交窗口):HEAD~1..HEAD ∪ porcelain(初提交仓 HEAD~1 不存在时
322
+ // 两取数全失败 → 落 design-list)
323
+ const face = gitFace(cwd, 'HEAD~1..HEAD')
324
+ if (face) {
325
+ source = 'in-place'
326
+ mainFiles.push(...face)
327
+ out.notes.push('文件源=in-place diff(HEAD~1..HEAD ∪ porcelain,含已提交窗口)')
328
+ }
329
+ }
330
+
331
+ // ── local.yaml 注册表(跨仓直采 + design 清单 repoKeys 解析共用;specBase/local.yaml 优先,
332
+ // 兼容 cwd/.sillyspec/local.yaml——runProbe8PayloadParity / collectCrossRepoDiffRoots 双读点合一)──
333
+ let registry = new Map()
334
+ try {
335
+ for (const yamlPath of [join(specBase, 'local.yaml'), join(cwd, '.sillyspec', 'local.yaml')]) {
336
+ if (!existsSync(yamlPath)) continue
337
+ registry = parseRepoRegistry(readFileSync(yamlPath, 'utf8'))
338
+ if (registry.size > 0) break
339
+ }
340
+ } catch { /* 注册表不可读 → 跨仓空面(fail-open) */ }
341
+ const repoRootOf = (raw) => (isAbsolute(raw) ? raw : resolve(cwd, raw))
342
+
343
+ // ── design 清单声明面(③态文件源 + 差集基准;NEW: 待建前缀剥除,probe1-new-prefix-miss 同款)──
344
+ const designPath = join(specBase, 'changes', changeName, 'design.md')
345
+ let designList = []
346
+ try {
347
+ if (existsSync(designPath)) designList = parseFileChangeListDetailed(designPath, { repoKeys: repoKeys || [...registry.keys()] })
348
+ } catch { designList = [] }
349
+ const designNorm = []
350
+ for (const e of designList) {
351
+ let p = String((e && e.path) || '').replace(/^NEW:\s*/, '').split('\\').join('/').trim()
352
+ let repo = (e && e.repo) || null
353
+ if (!p || p.startsWith('.sillyspec/')) continue
354
+ // 未注册跨仓前缀兜底识别(change-list 只对注册 key 落 e.repo):前缀剥掉按路径启发式
355
+ // 分类 + 未注册注记(runProbe8 readEntry 同款现身原则,不静默吞)
356
+ if (!repo) {
357
+ const cr = p.match(/^cross-repo:([A-Za-z0-9_.\-]+):(.*)$/)
358
+ if (cr) {
359
+ p = cr[2]
360
+ out.notes.push(`repo「${cr[1]}」未在 local.yaml repos 注册——该仓文件不进 diff 面,按路径启发式分类`)
361
+ }
362
+ }
363
+ if (p) designNorm.push({ repo, path: p })
364
+ }
365
+
366
+ // ── 跨仓双源直采(diff/in-place 态;design-list 态文件源即清单面不直采)──
367
+ const fileEntries = [] // { path, roots[] }(roots = 文件头 10 行读取候选根,先主后次)
368
+ if (source === 'diff' || source === 'in-place') {
369
+ for (const f of mainFiles) fileEntries.push({ path: f, roots: [mainRoot] })
370
+ for (const [key, raw] of registry.entries()) {
371
+ try {
372
+ if (!raw) continue
373
+ const root = repoRootOf(raw)
374
+ if (!root || !existsSync(root) || resolve(root) === resolve(cwd)) continue // 主仓自身不重复计
375
+ const face = gitFace(root, 'HEAD~1..HEAD')
376
+ if (!face) { out.notes.push(`跨仓「${key}」diff 取数失败——跳过`); continue }
377
+ for (const f of face) fileEntries.push({ path: f, roots: [root] })
378
+ } catch { out.notes.push(`跨仓「${key}」采集异常——跳过`) }
379
+ }
380
+ } else {
381
+ out.notes.push('文件源=design 清单(diff 不可用)')
382
+ for (const e of designNorm) {
383
+ const roots = e.repo && registry.has(e.repo)
384
+ ? [repoRootOf(registry.get(e.repo))]
385
+ : [wt && resolve(wt.gitDir) !== resolve(cwd) ? wt.gitDir : null, cwd].filter(Boolean)
386
+ fileEntries.push({ path: e.path, roots })
387
+ }
388
+ }
389
+
390
+ // ── design 差集(advisory):design 清单有但 diff 面无(design-list 态清单即面 → 恒空)──
391
+ if (source === 'diff' || source === 'in-place') {
392
+ // 主仓面直接对集合;跨仓面按 roots[0] 反查仓根对应的 fileEntries 集合(多仓同根退化为主
393
+ // 仓口径,advisory 可接受;未注册 repo 无根可查 → 其条目恒落 design-only,如实现身不静默吞)
394
+ const mainFace = new Set(mainFiles)
395
+ const faceByRoot = new Map()
396
+ const faceAt = (root) => {
397
+ const abs = resolve(root)
398
+ if (!faceByRoot.has(abs)) {
399
+ faceByRoot.set(abs, new Set(fileEntries.filter(fe => resolve(fe.roots[0]) === abs).map(fe => fe.path)))
400
+ }
401
+ return faceByRoot.get(abs)
402
+ }
403
+ for (const e of designNorm) {
404
+ if (e.repo) {
405
+ const face = registry.has(e.repo) ? faceAt(repoRootOf(registry.get(e.repo))) : new Set()
406
+ if (!face.has(e.path)) out.designOnlyPaths.push(`cross-repo:${e.repo}:${e.path}`)
407
+ } else if (!mainFace.has(e.path)) {
408
+ out.designOnlyPaths.push(e.path)
409
+ }
410
+ }
411
+ }
412
+
413
+ // ── 分类(.ts/.tsx 目录启发式未裁决时才读文件头 10 行,省 IO;读不到按无头处理)──
414
+ const head10 = (roots, relPath) => {
415
+ for (const r of roots || []) {
416
+ try { return readFileSync(join(r, relPath), 'utf8').split('\n').slice(0, 10).join('\n') } catch { /* 试下一根 */ }
417
+ }
418
+ return null
419
+ }
420
+ const cls = { frontend: [], backend: [], other: [] }
421
+ const seen = new Set()
422
+ for (const fe of fileEntries) {
423
+ if (seen.has(fe.path)) continue
424
+ seen.add(fe.path)
425
+ let c
426
+ if (/\.(ts|tsx)$/i.test(fe.path)) {
427
+ const words = probe8DirWords(fe.path)
428
+ const dirResolved = words.some(w => PROBE8_FE_DIR_WORDS.has(w) || PROBE8_BE_DIR_WORDS.has(w))
429
+ c = classifyProbe8FilePath(fe.path, dirResolved ? null : head10(fe.roots, fe.path))
430
+ } else {
431
+ c = classifyProbe8FilePath(fe.path)
432
+ }
433
+ cls[c].push(fe.path)
434
+ }
435
+ out.source = source || 'design-list'
436
+ out.fallbackMode = out.source
437
+ out.diffFiles = [...seen].sort()
438
+ out.frontend = cls.frontend
439
+ out.backend = cls.backend
440
+ out.other = cls.other
441
+ out.fileClassification = { frontend: cls.frontend, backend: cls.backend, other: cls.other }
442
+ out.designOnlyPaths.sort()
443
+ return out
444
+ }
445
+
446
+ // ── probe8 前端载荷字段提取器(task-02,2026-09-18-probe8-direct-compare / D-002@v1)──
447
+ // 前端面从「单文件键集」(extractPayloadKeys)升级为「字段 + 首命中行号 + URL 关联素材」三元组,
448
+ // 供 task-04 前端×后端直查对账。三后缀选匹配族(.js/.ts/.jsx/.tsx 基础四形态;.vue 追加
449
+ // v-model/prop;.wxml 追加 value 插值/data- 前缀);DTO 键仅收请求调用 8 行邻近窗口
450
+ // (R-08/Grill B-5:全文件字面量键污染防线,窗口机制对齐 extractPayloadKeys)。
451
+ // 请求调用定位族——词边界前置防 myDelete(/onSearch( 类标识符内误命中(EHS onDelete 形态)
452
+ const PROBE8_REQUEST_CALL_RE = /\b(?:fetch|post|put|delete|getRequest|postRequest|putRequest|deleteRequest|search)\s*\(/
453
+ // DTO 对象字面量起始键({ key: 形态,{ 与键同行);控制流/声明关键字经保留字 Set 排除
454
+ const PROBE8_DTO_KEY_RE = /[{]\s*([a-zA-Z]\w+)\s*:/g
455
+ const PROBE8_DTO_KEY_RESERVED = new Set([
456
+ 'if', 'for', 'while', 'switch', 'return', 'function', 'const', 'let', 'var', 'new', 'typeof',
457
+ 'await', 'try', 'catch', 'else', 'do', 'case', 'default', 'export', 'import', 'class',
458
+ 'extends', 'super', 'this', 'null', 'undefined', 'true', 'false',
459
+ ])
460
+ // js 族基础三正则(+ 上方 DTO 邻近窗口族);带 g 供 matchAll(matchAll 不推进原正则 lastIndex)
461
+ const PROBE8_FE_FORMDATA_RE = /formData\.([a-zA-Z]\w+)/g
462
+ const PROBE8_FE_PAYLOAD_RE = /payload\.([a-zA-Z]\w+)/g
463
+ const PROBE8_FE_NAME_RE = /name="(\w+)"/g
464
+ // vue 追加:v-model 绑定值 + prop 属性值((?<![:\w]) 排 vue 动态绑定 :prop= 与连写词内 prop=)
465
+ const PROBE8_FE_VMODEL_RE = /v-model="(\w+)"/g
466
+ const PROBE8_FE_PROP_RE = /(?<![:\w])prop="(\w+)"/g
467
+ // wxml 追加:value 插值绑定值 + data- 前缀属性名(事件传参 data-xxx= 形态)
468
+ const PROBE8_FE_WXML_VALUE_RE = /value="{{(\w+)}}"/g
469
+ const PROBE8_FE_WXML_DATA_RE = /data-(\w+)=/g
470
+ // URL 捕获:请求调用首参(引号/反引号/斜杠起始;变量首参不收,fail-visible 保守面)
471
+ const PROBE8_URL_CALL_RE = /\b(?:fetch|post|put|delete|getRequest|postRequest|putRequest|deleteRequest|search)\s*\(\s*['"`/]([^'"`,\s]+)/g
472
+
473
+ /** 字段名归一:snake_case → lowerCamel(_x → X,_ 后随字母数字大写化) */
474
+ const camelProbe8Field = (s) => String(s || '').replace(/_([A-Za-z0-9])/g, (_, c) => c.toUpperCase())
475
+
476
+ /** URL 归一化三步:去 ?query → 去首 /api/ 前缀 → 去尾斜杠(task-04 段边界匹配消费口径) */
477
+ const normalizeProbe8Url = (u) => String(u || '')
478
+ .split('?')[0]
479
+ .replace(/^\/?api\//, '')
480
+ .replace(/\/+$/, '')
481
+
482
+ /**
483
+ * 段边界后缀匹配(task-02):前端归一 URL 是否为后端完整 path 的路径段边界后缀——
484
+ * backendPath === frontendUrl ‖ 以 '/'+frontendUrl 收尾 ‖ 以 frontendUrl+'/' 收尾。
485
+ * 段首对齐:'/orders' 命中 '/api/v1/orders' 不命中 '/rporders'(裸 endsWith 的段中假阳防线)。
486
+ * @param {string} frontendUrl 前端归一 URL
487
+ * @param {string} backendPath 后端完整 path(类级+方法级 @RequestMapping 拼接形态)
488
+ * @returns {boolean}
489
+ */
490
+ export function isSegmentSuffix(frontendUrl, backendPath) {
491
+ // 前后都归一去前导/尾随斜杠再比——修前导斜杠缺口(task-06 发现:'/' + '/orders'
492
+ // 拼成 '//orders' 永不命中,非 /api/ 前缀前端 URL 对 /api 前缀后端端点静默失联)
493
+ const fe = String(frontendUrl || '').trim().replace(/^\/+|\/+$/g, '')
494
+ const be = String(backendPath || '').trim().replace(/^\/+|\/+$/g, '')
495
+ if (!fe || !be) return false
496
+ return be === fe || be.endsWith('/' + fe)
497
+ }
498
+
499
+ /**
500
+ * extractFrontendPayloadFields——前端载荷构造点字段提取(task-02)。
501
+ * - 逃生门:文件前 5 行任意一行含 probe8-skip → escapeHatch=true 整文件跳过
502
+ * (probe9-skip 文件级豁免同款先例;不限定注释形态——js/vue/wxml 注释语法各异)。
503
+ * - fields:归一(snake→lowerCamel)后字段名数组(首命中序,Set 语义去重);
504
+ * - fieldLines:Map<归一字段, 首命中行号(1-based,多族命中取最小行号)>——task-04
505
+ * driftWarnings 行号来源;
506
+ * - urlsByCall:{ url, normalizedUrl, line }[] 逐请求调用记录——无 URL 文件产出空数组
507
+ * (未关联端点面处置归 task-04)。
508
+ * 三后缀之外的文件返回空面(escapeHatch=false)。
509
+ * @param {string} filePath 仓根相对路径(反斜杠/大小写容忍)
510
+ * @param {string} content 文件全文
511
+ * @returns {{ escapeHatch: boolean, fields: string[], fieldLines: Map<string, number>,
512
+ * urlsByCall: Array<{ url: string, normalizedUrl: string, line: number}> }}
513
+ */
514
+ export function extractFrontendPayloadFields(filePath, content) {
515
+ const text = String(content || '')
516
+ // 前 5 行逃生门(slice(0,5) 即第 1-5 行)
517
+ if (text.split('\n').slice(0, 5).some(l => l.includes('probe8-skip'))) {
518
+ return { escapeHatch: true, fields: [], fieldLines: new Map(), urlsByCall: [] }
519
+ }
520
+ const p = String(filePath || '').split('\\').join('/').toLowerCase()
521
+ const isJs = /\.(?:js|ts|jsx|tsx)$/.test(p)
522
+ const isVue = p.endsWith('.vue')
523
+ const isWxml = p.endsWith('.wxml')
524
+ if (!isJs && !isVue && !isWxml) {
525
+ return { escapeHatch: false, fields: [], fieldLines: new Map(), urlsByCall: [] }
526
+ }
527
+
528
+ const lines = text.split('\n')
529
+ const fieldLines = new Map() // 归一字段 → 首命中行号(行级族与 DTO 族独立扫描,合并取最小)
530
+ const record = (raw, line) => {
531
+ const f = camelProbe8Field(raw)
532
+ if (!f) return
533
+ const prev = fieldLines.get(f)
534
+ if (prev === undefined || line < prev) fieldLines.set(f, line)
535
+ }
536
+
537
+ // ── 行级匹配族:js 基础三正则(formData./payload./name=)+ vue/wxml 追加族 ──
538
+ const lineRes = [PROBE8_FE_FORMDATA_RE, PROBE8_FE_PAYLOAD_RE, PROBE8_FE_NAME_RE]
539
+ if (isVue) lineRes.push(PROBE8_FE_VMODEL_RE, PROBE8_FE_PROP_RE)
540
+ if (isWxml) lineRes.push(PROBE8_FE_WXML_VALUE_RE, PROBE8_FE_WXML_DATA_RE)
541
+ for (let i = 0; i < lines.length; i++) {
542
+ for (const re of lineRes) {
543
+ for (const m of lines[i].matchAll(re)) record(m[1], i + 1)
544
+ }
545
+ }
546
+
547
+ // ── 请求调用:调用行起 8 行窗口 DTO 键(窗口口径对齐 extractPayloadKeys)+ 首参 URL 捕获 ──
548
+ const urlsByCall = []
549
+ for (let i = 0; i < lines.length; i++) {
550
+ if (!PROBE8_REQUEST_CALL_RE.test(lines[i])) continue
551
+ for (let j = i; j < Math.min(i + 8, lines.length); j++) {
552
+ for (const m of lines[j].matchAll(PROBE8_DTO_KEY_RE)) {
553
+ if (!PROBE8_DTO_KEY_RESERVED.has(m[1])) record(m[1], j + 1)
554
+ }
555
+ }
556
+ for (const um of lines[i].matchAll(PROBE8_URL_CALL_RE)) {
557
+ urlsByCall.push({ url: um[1], normalizedUrl: normalizeProbe8Url(um[1]), line: i + 1 })
558
+ }
559
+ }
560
+ return { escapeHatch: false, fields: [...fieldLines.keys()], fieldLines, urlsByCall }
561
+ }
562
+
563
+ // ── probe8 后端字段两趟提取器(task-03,2026-09-18-probe8-direct-compare / D-003@v1)──
564
+ // 后端面两趟结构(Grill B-4——@RequestBody 类型名跨文件解析,单文件签名不可行):一趟 Controller
565
+ // 定位(类级 @RequestMapping 前缀 × 方法级动词注解 path 拼接完整端点)+ 方法签名区参数提取
566
+ // (Grill B-3 修正正则);二趟 @RequestBody 类型名经 resolveTypeContent 全仓回调解析出字段集
567
+ // (R-07——变更不动实体时不再全量假阳:只解析被引用类型非全仓字段;全仓性体现在检索面由回调
568
+ // 承担)。函数本体零文件系统 IO(纯函数可测)——全仓检索经回调注入,工厂 createTypeResolver
569
+ // 另行导出供调用侧组装(task-05 接线)。
570
+ // 类级前缀:@RequestMapping(value = "/x") 且位置在 class 声明前(首个命中)
571
+ const PROBE8_CLASS_PREFIX_RE = /@RequestMapping\(\s*(?:value\s*=\s*)?["']([^"']+)["']/
572
+ // class 声明锚(前缀只认首个 class/interface/enum 声明之前;方法级注解按 index 与之比较)
573
+ const PROBE8_CLASS_DECL_RE = /\b(?:class|interface|enum)\s+\w+/
574
+ // 含任一 mapping 注解 → 本文件进一趟端点提取(service/entity 的 .java 不含,天然排除)
575
+ const PROBE8_CONTROLLER_RE = /@(?:RequestMapping|GetMapping|PostMapping|PutMapping|DeleteMapping|PatchMapping)\b/
576
+ // 方法级端点(动词组含 Request → 无动词时 'REQUEST'):注解 path → 注解余段 [^)]* → 方法签名
577
+ // 可见性修饰符。m[2]=方法子路径;m[0] 尾部止于可见性修饰符后(签名区起点 = m.index+m[0].length)
578
+ const PROBE8_METHOD_MAPPING_RE = /@(Get|Post|Put|Delete|Patch|Request)Mapping\(\s*(?:value\s*=\s*)?["']([^"']+)["'][^)]*\)\s*(?:public|protected|private)\s+/g
579
+ // 方法参数三注解(Grill B-3 修正——捕获组 2=参数名:可选注解参数串 + 可选 final + 泛型/数组
580
+ // 类型(Grill F-6——String[]/List<String> 方括号泛型边界不误捕)+ 最后一个标识符;@RequestParam
581
+ // 注解参数串含 required=false → 该参数非必填)
582
+ const PROBE8_REQUEST_PARAM_RE = /@RequestParam\s*(?:\(([^)]*)\))?\s+(?:final\s+)?(?:\w+(?:<[^>]+>)?(?:\[\])?)\s+(\w+)/g
583
+ // @RequestParam 注解显式 value 名命中时参数名取它(括号紧邻裸串或 value= 形态;defaultValue=
584
+ // 等其他键不命中——括号紧邻约束保证)
585
+ const PROBE8_REQUEST_PARAM_VALUE_RE = /@RequestParam\(\s*(?:value\s*=\s*)?["'](\w+)["']/
586
+ const PROBE8_REQUIRED_FALSE_RE = /required\s*=\s*false/
587
+ const PROBE8_PATH_VARIABLE_RE = /@PathVariable\s+(?:final\s+)?(?:\w+\s+)?(\w+)/g
588
+ const PROBE8_REQUEST_BODY_RE = /@RequestBody\s+(?:final\s+)?(\w+)/g
589
+ // 必填形态①(D-003):校验注解行起向下 5 行窗口内首个 private 字段声明
590
+ const PROBE8_NOT_NULL_RE = /@(?:NotNull|NotBlank|NotEmpty)\b/
591
+ const PROBE8_PRIVATE_FIELD_RE = /private\s+\w+(?:<[^>]+>)?(?:\[\])?\s+(\w+)\s*;/
592
+ // 必填形态③:方法体前 30 行校验调用三模式(StringBlankValidator 首参 / Valid.valid 同行或
593
+ // 上一行字段名 / if 判空字段名)
594
+ const PROBE8_SBV_RE = /StringBlankValidator\s*\(\s*["'](\w+)["']/
595
+ const PROBE8_VALID_LINE_RE = /\bValid\.valid/
596
+ const PROBE8_VALID_ARG_RE = /\bValid\.valid\w*\(\s*(\w+)/
597
+ // 上一行标识符宽收(形态③「上一行字段名」落点):小写起始 + 不紧邻 `(`(排调用名)+ 停用词集
598
+ const PROBE8_VALID_IDENT_RE = /\b([a-z][A-Za-z0-9]*)\b(?!\s*\()/g
599
+ const PROBE8_VALID_IDENT_STOP = new Set([
600
+ 'valid', 'final', 'new', 'return', 'if', 'else', 'while', 'for', 'switch', 'case', 'catch',
601
+ 'null', 'true', 'false', 'this', 'super', 'static', 'public', 'private', 'protected',
602
+ 'string', 'integer', 'int', 'long', 'double', 'float', 'boolean', 'date', 'object',
603
+ ])
604
+ const PROBE8_IF_NULL_RE = /if\s*\(\s*(\w+)\s*==\s*null/
605
+ // 实体/dto 文件形(diff 面直接解析不依赖 @RequestBody 引用链)
606
+ const PROBE8_ENTITY_CLASS_RE = /class\s+\w+\s*(?:extends|implements|\{)/
607
+
608
+ /** 端点 path 拼接:prefix × 方法子路径,去重复 /("/api/" + "/add" → "/api/add") */
609
+ function joinProbe8Path(prefix, methodPath) {
610
+ const p = String(prefix || '')
611
+ const m = String(methodPath || '')
612
+ if (!p) return m
613
+ if (!m) return p
614
+ return (p.endsWith('/') ? p.slice(0, -1) : p) + (m.startsWith('/') ? m : '/' + m)
615
+ }
616
+
617
+ /** private 字段声明名集(二趟 DTO / diff 面实体共用;g 正则局部化 matchAll,无共享 lastIndex 风险) */
618
+ function probe8PrivateFieldNames(content) {
619
+ const out = new Set()
620
+ for (const m of String(content || '').matchAll(/private\s+\w+(?:<[^>]+>)?(?:\[\])?\s+(\w+)\s*;/g)) out.add(m[1])
621
+ return out
622
+ }
623
+
624
+ /** 必填形态①(D-003):@(NotNull|NotBlank|NotEmpty) 行起向下 5 行窗口内首个 private 字段声明名集 */
625
+ function probe8NotNullFields(content) {
626
+ const lines = String(content || '').split('\n')
627
+ const out = new Set()
628
+ for (let i = 0; i < lines.length; i++) {
629
+ if (!PROBE8_NOT_NULL_RE.test(lines[i])) continue
630
+ for (let j = i; j < Math.min(i + 5, lines.length); j++) {
631
+ const fm = lines[j].match(PROBE8_PRIVATE_FIELD_RE)
632
+ if (fm) { out.add(fm[1]); break }
633
+ }
634
+ }
635
+ return out
636
+ }
637
+
638
+ /**
639
+ * 必填形态③(D-003):方法体前 30 行校验调用三模式字段名集——
640
+ * StringBlankValidator("x") 首参 / Valid.valid 同行首参 + 上一行标识符宽收(R-02 宁多勿漏)/
641
+ * if (x == null) 判空字段。
642
+ */
643
+ function probe8BodyCheckFields(winLines) {
644
+ const out = new Set()
645
+ for (let k = 0; k < winLines.length; k++) {
646
+ const line = winLines[k]
647
+ const sbv = line.match(PROBE8_SBV_RE)
648
+ if (sbv) out.add(sbv[1])
649
+ const ifn = line.match(PROBE8_IF_NULL_RE)
650
+ if (ifn) out.add(ifn[1])
651
+ if (PROBE8_VALID_LINE_RE.test(line)) {
652
+ const arg = line.match(PROBE8_VALID_ARG_RE)
653
+ if (arg) out.add(arg[1])
654
+ if (k > 0) {
655
+ for (const idm of winLines[k - 1].matchAll(PROBE8_VALID_IDENT_RE)) {
656
+ if (!PROBE8_VALID_IDENT_STOP.has(idm[1])) out.add(idm[1])
657
+ }
658
+ }
659
+ }
660
+ }
661
+ return out
662
+ }
663
+
664
+ /**
665
+ * extractBackendFields——后端面两趟提取(task-03,D-003@v1)。
666
+ * - 逃生门:文件前 5 行任意一行含 probe8-skip → escapeHatchCount++ 整文件跳过(与前端
667
+ * task-02 同门,不限定注释形态;优先于非 Java 判定——hatch 是整文件主动退出);
668
+ * - 非 Java:分类面 backend 但非 .java(目录启发式命中的 .ts 等)→ nonJavaSkipCount++;
669
+ * - 一趟 Controller(含六个 mapping 注解任一):类级 @RequestMapping 前缀 × 方法级动词注解
670
+ * path 拼接完整端点(去重复 /);方法级 @RequestMapping → method='REQUEST';prefix 存在
671
+ * 但零方法级端点 → 类级通配 {prefix, 'REQUEST'};方法签名区(注解尾至方法体首 `{`)提取
672
+ * @RequestParam(三态:正常参数名 / required=false 非必填 / value 注解显式名)/ @PathVariable
673
+ * (URL 段参数,非载荷字段只进参数集)/ @RequestBody(类型名收二趟待解析集);
674
+ * - 必填三形态(D-003):①校验注解 5 行窗口(二趟类型内容上扫,命中归引用该类型的端点);
675
+ * ②@RequestParam 排除 required=false 后全必填(Spring 缺省 true);③方法体前 30 行校验
676
+ * 调用三模式;
677
+ * - 二趟:@RequestBody 类型名经 resolveTypeContent(typeName) 全仓回调解析(返 null / 抛错
678
+ * 均 fail-soft 不炸)→ private 字段集 = 端点 bodyFields(并入 entityFields)+ ①必填扫描;
679
+ * diff 面非 Controller 实体文件(class 声明形)直接解析(不依赖 @RequestBody 引用链);
680
+ * - backendAllFields = 二趟类型字段 ∪ 一趟 Controller 方法参数名 ∪ diff 面实体字段
681
+ * (全仓并集非仅 diff 面,Grill B-7)。
682
+ * @param {Array<{path: string, content: string}>} files 分类面 backend 文件集
683
+ * @param {(typeName: string) => string|null} [resolveTypeContent] 二趟全仓类型定义查找回调
684
+ * @returns {{ entityFields: string[], endpoints: Array<{path: string, method: string,
685
+ * requiredFields: string[], bodyFields: string[]}>, backendAllFields: string[],
686
+ * escapeHatchCount: number, nonJavaSkipCount: number }}
687
+ */
688
+ export function extractBackendFields(files, resolveTypeContent) {
689
+ const resolve = typeof resolveTypeContent === 'function' ? resolveTypeContent : () => null
690
+ const entityFields = new Set() // 二趟类型字段 ∪ diff 面实体字段
691
+ const controllerParams = new Set() // 一趟全部 Controller 方法参数名
692
+ const endpoints = [] // {path, method, required:Set, bodyFields:Set, bodyTypes:string[]}
693
+ let escapeHatchCount = 0
694
+ let nonJavaSkipCount = 0
695
+
696
+ for (const f of Array.isArray(files) ? files : []) {
697
+ const path = String((f && f.path) || '').split('\\').join('/')
698
+ const text = String((f && f.content) || '')
699
+ if (text.split('\n').slice(0, 5).some(l => l.includes('probe8-skip'))) { escapeHatchCount++; continue }
700
+ if (!path.toLowerCase().endsWith('.java')) { nonJavaSkipCount++; continue }
701
+
702
+ if (!PROBE8_CONTROLLER_RE.test(text)) {
703
+ // 非 Controller:diff 面实体/dto 直接解析(class 声明形,不依赖 @RequestBody 引用链)
704
+ if (PROBE8_ENTITY_CLASS_RE.test(text)) {
705
+ for (const name of probe8PrivateFieldNames(text)) entityFields.add(name)
706
+ }
707
+ continue
708
+ }
709
+
710
+ // ── 一趟:类级前缀 + 方法级端点 ──
711
+ const lines = text.split('\n')
712
+ const classDecl = text.match(PROBE8_CLASS_DECL_RE)
713
+ const classIdx = classDecl ? classDecl.index : -1
714
+ let prefix = ''
715
+ const pm = text.match(PROBE8_CLASS_PREFIX_RE)
716
+ if (pm && (classIdx < 0 || pm.index < classIdx)) prefix = pm[1]
717
+
718
+ let fileEndpointCount = 0
719
+ for (const m of text.matchAll(PROBE8_METHOD_MAPPING_RE)) {
720
+ if (classIdx >= 0 && m.index < classIdx) continue // 类级 @RequestMapping 非方法端点
721
+ const method = m[1] === 'Request' ? 'REQUEST' : m[1].toUpperCase()
722
+ // 方法签名区:注解尾(m[0] 止于可见性修饰符)至方法体首 `{`;无 `{`(抽象/接口)取到文末
723
+ const sigStart = m.index + m[0].length
724
+ const braceIdx = text.indexOf('{', sigStart)
725
+ const sigText = text.slice(sigStart, braceIdx < 0 ? text.length : braceIdx)
726
+ const ep = {
727
+ path: joinProbe8Path(prefix, m[2]),
728
+ method,
729
+ required: new Set(),
730
+ bodyFields: new Set(),
731
+ bodyTypes: [],
732
+ }
733
+ // @RequestParam 三态(数组/泛型边界不误捕;value 注解显式名优先于声明参数名)
734
+ for (const rp of sigText.matchAll(PROBE8_REQUEST_PARAM_RE)) {
735
+ const annoSeg = rp[1] === undefined ? '' : `@RequestParam(${rp[1]})`
736
+ const vm = annoSeg ? annoSeg.match(PROBE8_REQUEST_PARAM_VALUE_RE) : null
737
+ const name = vm ? vm[1] : rp[2]
738
+ controllerParams.add(name)
739
+ if (!(rp[1] && PROBE8_REQUIRED_FALSE_RE.test(rp[1]))) ep.required.add(name) // ② Spring 缺省必填
740
+ }
741
+ // @PathVariable 参数名进一趟参数集(URL 段非载荷字段,不计端点必填)
742
+ for (const pv of sigText.matchAll(PROBE8_PATH_VARIABLE_RE)) controllerParams.add(pv[1])
743
+ // @RequestBody 类型名收二趟待解析集
744
+ for (const rb of sigText.matchAll(PROBE8_REQUEST_BODY_RE)) ep.bodyTypes.push(rb[1])
745
+ // ③ 方法体前 30 行校验调用三模式 → 端点必填
746
+ if (braceIdx >= 0) {
747
+ const braceLine = text.slice(0, braceIdx).split('\n').length // `{` 所在行(1-based)
748
+ const win = lines.slice(braceLine - 1, braceLine - 1 + 30)
749
+ for (const name of probe8BodyCheckFields(win)) ep.required.add(name)
750
+ }
751
+ endpoints.push(ep)
752
+ fileEndpointCount++
753
+ }
754
+ // prefix 存在但零方法级端点 → 类级通配(无动词可判,method='REQUEST')
755
+ if (prefix && fileEndpointCount === 0) {
756
+ endpoints.push({ path: prefix, method: 'REQUEST', required: new Set(), bodyFields: new Set(), bodyTypes: [] })
757
+ }
758
+ }
759
+
760
+ // ── 二趟:@RequestBody 类型名经全仓回调解析(typeCache 去重同名类型重复解析)──
761
+ const typeCache = new Map()
762
+ for (const ep of endpoints) {
763
+ for (const tn of ep.bodyTypes) {
764
+ if (!typeCache.has(tn)) {
765
+ let c = null
766
+ try { c = resolve(tn) } catch { c = null } // 回调抛错 fail-soft 视为 null
767
+ typeCache.set(tn, typeof c === 'string' ? c : null)
768
+ }
769
+ const content = typeCache.get(tn)
770
+ if (!content) continue // 类型不可解析(null)不炸——bodyFields 留空
771
+ for (const name of probe8PrivateFieldNames(content)) { ep.bodyFields.add(name); entityFields.add(name) }
772
+ // ① 校验注解 5 行窗口必填 → 归引用该类型的端点
773
+ for (const name of probe8NotNullFields(content)) ep.required.add(name)
774
+ }
775
+ }
776
+
777
+ const backendAllFields = new Set([...entityFields, ...controllerParams])
778
+ return {
779
+ entityFields: [...entityFields].sort(),
780
+ endpoints: endpoints.map(e => ({
781
+ path: e.path,
782
+ method: e.method,
783
+ requiredFields: [...e.required].sort(),
784
+ bodyFields: [...e.bodyFields].sort(),
785
+ })),
786
+ backendAllFields: [...backendAllFields].sort(),
787
+ escapeHatchCount,
788
+ nonJavaSkipCount,
789
+ }
790
+ }
791
+
792
+ // 二趟全仓检索的 .java 文件大小上限(Grill F-6——超大生成文件不进类型表)
793
+ const PROBE8_JAVA_FILE_CAP = 500 * 1024
794
+
795
+ /**
796
+ * createTypeResolver——二趟全仓类型解析回调工厂(task-05 调用侧组装)。readdirSync 递归扫
797
+ * srcRoot 下 .java(statSync 大小 cap 500KB,超限跳过),内容首个 class/interface/enum 声明名
798
+ * (缺失退文件名 stem)→ Map<className, content>;同名类先见先留(不覆写不炸)。srcRoot 不可
799
+ * 读/不存在 → 空表闭包(恒返 null,fail-soft)。做 IO 的是本工厂非 extractBackendFields 本体
800
+ * (纯函数约束)。
801
+ * @param {string} srcRoot 扫描根(仓根的 src/ 或 src/main/java 等——由调用侧定)
802
+ * @returns {(typeName: string) => string|null}
803
+ */
804
+ export function createTypeResolver(srcRoot) {
805
+ const typeMap = new Map()
806
+ const walk = (dir) => {
807
+ let entries
808
+ try { entries = readdirSync(dir, { withFileTypes: true }) } catch { return }
809
+ for (const e of entries) {
810
+ const full = join(dir, e.name)
811
+ try {
812
+ if (e.isDirectory()) { walk(full); continue }
813
+ if (!e.isFile() || !e.name.toLowerCase().endsWith('.java')) continue
814
+ if (statSync(full).size > PROBE8_JAVA_FILE_CAP) continue
815
+ const content = readFileSync(full, 'utf8')
816
+ const decl = content.match(/\b(?:class|interface|enum)\s+([A-Za-z_]\w*)/)
817
+ const name = (decl && decl[1]) || e.name.replace(/\.java$/, '')
818
+ if (!typeMap.has(name)) typeMap.set(name, content)
819
+ } catch { /* 单文件不可读——跳过 */ }
820
+ }
821
+ }
822
+ walk(String(srcRoot || ''))
823
+ return (typeName) => typeMap.get(typeName) || null
824
+ }
825
+
826
+ // ── probe8 direct-compare 对账+渲染(task-04,2026-09-18-probe8-direct-compare / D-002@v1·D-004@v1)──
827
+ // 消费 task-02 前端提取面(fields/fieldLines/urlsByCall)× task-03 后端提取面(endpoints/
828
+ // backendAllFields)做代码级字段直比,两嫌疑面(Grill B-7——backendAllFields 全仓并集防全量假阳):
829
+ // - 漂移嫌疑:前端发送字段 ∉ backendAllFields(Jackson 静默丢弃风险面);
830
+ // - 必填漏发嫌疑:端点 requiredFields ∉ 该端点关联前端文件发送集(EHS 缺发 reportOrgId 同族)。
831
+ // 纯函数零 IO、advisory 档(D-004——不进 errors/warnings,独立渲染子段,命中统计供后续批次
832
+ // 评估升格)。
833
+
834
+ /**
835
+ * comparePayloadFields——前端×后端字段直比对账(task-04,纯函数零 IO)。
836
+ * - 漂移嫌疑:非 escapeHatch 前端文件的每个 field ∉ backendAllFields → driftWarnings 逐条
837
+ * {file, line(fieldLines 首命中行号,缺失 null), field};
838
+ * - 必填漏发嫌疑:端点 requiredFields ∉ 该端点关联前端文件发送集 → missingRequiredWarnings
839
+ * 逐条 {endpoint, method, field, frontendFiles}。关联判定 = 端点 path 与文件 urlsByCall 的
840
+ * normalizedUrl 满足 isSegmentSuffix(段边界后缀,Grill B-8——/orders 命中 /api/v1/orders
841
+ * 不误命中 /rporders;全等分支容忍无 /api 前缀可剥的归一原值如 /rporders)。无 URL 文件归
842
+ * 「未关联端点」面——仅漂移参与、漏发不参与(design §2 口径);端点零关联文件 → 跳过漏发
843
+ * 判定(无发送集可比,「端点无人调用」属覆盖面非载荷面);
844
+ * - escapeHatchCount = 前端面 escapeHatch 文件聚合 + backendStats.escapeHatchCount(后端 hatch
845
+ * 自两提取面透传);nonJavaSkipCount 自 backendStats 透传(后端提取面计数,三主参无承载点
846
+ * → 第四可选参,缺省零)。
847
+ * @param {Array<{filePath: string, extract: {escapeHatch: boolean, fields: string[],
848
+ * fieldLines: Map<string, number>, urlsByCall: Array<{url: string, normalizedUrl: string, line: number}>}}>} frontendByFile
849
+ * 逐前端文件提取面(task-02 产物按文件包装)
850
+ * @param {Array<{path: string, method: string, requiredFields: string[], bodyFields: string[]}>} backendEndpoints
851
+ * 后端端点集(task-03 产物 endpoints)
852
+ * @param {string[]} backendAllFields 后端全字段并集(task-03 产物 backendAllFields)
853
+ * @param {{escapeHatchCount?: number, nonJavaSkipCount?: number}} [backendStats]
854
+ * 后端提取面计数透传(缺省 {escapeHatchCount: 0, nonJavaSkipCount: 0})
855
+ * @returns {{ driftWarnings: Array<{file: string, line: number|null, field: string}>,
856
+ * missingRequiredWarnings: Array<{endpoint: string, method: string, field: string, frontendFiles: string[]}>,
857
+ * escapeHatchCount: number, nonJavaSkipCount: number }}
858
+ */
859
+ export function comparePayloadFields(frontendByFile, backendEndpoints, backendAllFields, backendStats) {
860
+ const feFiles = Array.isArray(frontendByFile) ? frontendByFile : []
861
+ const eps = Array.isArray(backendEndpoints) ? backendEndpoints : []
862
+ const beFields = new Set(Array.isArray(backendAllFields) ? backendAllFields : [])
863
+ const stats = backendStats && typeof backendStats === 'object' ? backendStats : {}
864
+ const statCount = (v) => (Number.isInteger(v) && v > 0 ? v : 0)
865
+
866
+ const driftWarnings = []
867
+ let feEscapeCount = 0
868
+ const urlLinkedFiles = [] // 有 URL 调用的文件 {filePath, fieldSet, urls}——漏发关联面
869
+ for (const fe of feFiles) {
870
+ const filePath = String((fe && fe.filePath) || '')
871
+ const ex = (fe && fe.extract) || {}
872
+ if (ex.escapeHatch) { feEscapeCount++; continue }
873
+ const fields = Array.isArray(ex.fields) ? ex.fields : []
874
+ for (const f of fields) {
875
+ if (!beFields.has(f)) {
876
+ const line = ex.fieldLines instanceof Map ? ex.fieldLines.get(f) : undefined
877
+ driftWarnings.push({ file: filePath, line: typeof line === 'number' ? line : null, field: f })
878
+ }
879
+ }
880
+ const urlsByCall = Array.isArray(ex.urlsByCall) ? ex.urlsByCall : []
881
+ if (urlsByCall.length === 0) continue // 无 URL 文件:未关联端点面——仅上方漂移参与
882
+ urlLinkedFiles.push({
883
+ filePath,
884
+ fieldSet: new Set(fields),
885
+ urls: urlsByCall.map(u => u && u.normalizedUrl).filter(Boolean),
886
+ })
887
+ }
888
+
889
+ const missingRequiredWarnings = []
890
+ for (const ep of eps) {
891
+ const path = String((ep && ep.path) || '')
892
+ const method = String((ep && ep.method) || '')
893
+ const required = Array.isArray(ep && ep.requiredFields) ? ep.requiredFields : []
894
+ if (required.length === 0) continue
895
+ const linked = urlLinkedFiles.filter(f => f.urls.some(u => isSegmentSuffix(u, path)))
896
+ if (linked.length === 0) continue // 零关联文件:无发送集可比,漏发不判(覆盖面归 API 矩阵)
897
+ const sentUnion = new Set()
898
+ for (const f of linked) for (const x of f.fieldSet) sentUnion.add(x)
899
+ for (const field of required) {
900
+ if (!sentUnion.has(field)) {
901
+ missingRequiredWarnings.push({ endpoint: path, method, field, frontendFiles: linked.map(f => f.filePath) })
902
+ }
903
+ }
904
+ }
905
+
906
+ return {
907
+ driftWarnings,
908
+ missingRequiredWarnings,
909
+ escapeHatchCount: feEscapeCount + statCount(stats.escapeHatchCount),
910
+ nonJavaSkipCount: statCount(stats.nonJavaSkipCount),
911
+ }
912
+ }
913
+
914
+ /** direct-compare 子段标题前缀(renderProbe8Lines 尾部子段调用锚) */
915
+ const PROBE8_DIRECT_COMPARE_PREFIX = '- direct-compare:'
916
+
917
+ /**
918
+ * renderDirectCompareSection——direct-compare 子段渲染(task-04,advisory 独立面)。
919
+ * 命中统计行 + 逐条明细行(漂移/漏发各一节);全零态输出「无命中」行。防撞约束(task 卡
920
+ * acceptance):明细行以 `` - ⚠️ ` `` 反引号开头,行首字面与 verify-postcheck.js 的
921
+ * PROBE8_CONTRACT_ORPHANS_LINE_RE / PROBE8_MISSING_REQUIRED_LINE_RE(`- ⚠️ 契约外载荷键 N 条`/
922
+ * `- ⚠️ 契约必填漏发 N 条` 字面前缀)严格不同——渲染行永不进两锚点的 N 求和(负例断言归
923
+ * task-06);PROBE1_HIT_LINE_RE 虽同形但按探针子节定界只统计 s1,本子段落 s8 不进其计数。
924
+ * @param {{ driftWarnings?: Array<{file: string, line: number|null, field: string}>,
925
+ * missingRequiredWarnings?: Array<{endpoint: string, method: string, field: string, frontendFiles: string[]}>,
926
+ * escapeHatchCount?: number, nonJavaSkipCount?: number }} compareResult
927
+ * @returns {string[]} 行数组(不含段标题——拼进探针 8 段尾部)
928
+ */
929
+ export function renderDirectCompareSection(compareResult) {
930
+ const r = compareResult && typeof compareResult === 'object' ? compareResult : {}
931
+ const drift = Array.isArray(r.driftWarnings) ? r.driftWarnings : []
932
+ const missing = Array.isArray(r.missingRequiredWarnings) ? r.missingRequiredWarnings : []
933
+ const esc = r.escapeHatchCount ?? 0
934
+ const nonJava = r.nonJavaSkipCount ?? 0
935
+ const L = []
936
+ if (drift.length === 0 && missing.length === 0) {
937
+ L.push(`${PROBE8_DIRECT_COMPARE_PREFIX} 无命中(漂移 0 / 漏发 0)`)
938
+ // 全零态仍保全跳过面计数(advisory 信息不丢——hatch/非 Java 跳过与命中数是独立维度)
939
+ if (esc > 0 || nonJava > 0) {
940
+ L.push(`- ℹ️ direct-compare: escape hatch ${esc} 文件 / 非 Java 后端跳过 ${nonJava} 文件`)
941
+ }
942
+ return L
943
+ }
944
+ L.push(`${PROBE8_DIRECT_COMPARE_PREFIX} 漂移嫌疑 ${drift.length} 条 / 必填漏发嫌疑 ${missing.length} 条 / escape hatch ${esc} 文件 / 非 Java 后端跳过 ${nonJava} 文件`)
945
+ for (const d of drift) {
946
+ L.push(`- ⚠️ \`${d.file}:${d.line ?? '?'}\` ${d.field} —— 不在后端字段集(漂移嫌疑)`)
947
+ }
948
+ for (const m of missing) {
949
+ L.push(`- ⚠️ \`${m.endpoint} ${m.method} ${m.field}\` —— 后端必填但前端未发送(必填漏发嫌疑,关联面:${(m.frontendFiles || []).join('、') || '?'})`)
950
+ }
951
+ return L
952
+ }
953
+
954
+
192
955
  // ── 探针 8 契约维度(task-01,2026-09-16 跨层契约探针扩展)──
193
956
  // design.md 的契约类章节(接口定义/数据模型等)字段表是「文档契约面」——既有三面(Java 字段/
194
957
  // SQL NOT NULL/前端载荷键)对不上「文档 vs 实现」漂移:契约改了代码没跟上(orphan)、契约必填
@@ -290,16 +1053,107 @@ function designHasSubmitEndpoint(text) {
290
1053
  return false
291
1054
  }
292
1055
 
1056
+ // ── 接口面解析 + 消费端归类(2026-09-17-api-coverage-smoke task-04 / FR-05 / D-005~D-007)──
1057
+ // parseDesignApiTable:design.md 接口段 tolerant 解析(D-005:不做 normative 接口表格式硬
1058
+ // 契约)。双重防线:段头宽收(含 接口/端点/API/REST 子串、不区分大小写的 ##/### 段——宁多
1059
+ // 勿漏,R-01:漏端点→漏覆盖假绿比多端点更糟)+ 行级双条件紧守(R-02 误报防护):HTTP 方法
1060
+ // token 词边界(GETTING/BUCKET 内嵌词形不误认)× 路径样式 token(/xxx 类路径或 {xxx}/:xxx
1061
+ // 模板段,取行内首个类路径串;?query 尾自然截断——端点身份是 path)。非接口段(非目标/
1062
+ // 先例引用段)内的表格/示例行靠段头过滤不计(「示例行跳过」的机制即此)。产物三面:
1063
+ // endpoints [{method, path, rowIdx}](rowIdx = 命中段内端点产出序号,1-based);
1064
+ // declared(「本变更接口面:N 端点」声明行提取的 N,全文首个命中,缺席 null——声明与
1065
+ // 解析并存以解析为准并注记,渲染侧体现);
1066
+ // sectionHint(命中的接口段头列表,审计注记)。
1067
+ // 落盘面(数据通道=落盘即消费,stage-contract 零 import 纪律):backfillFactsFromMdAndTests
1068
+ // 主路径写 facts.apiFace / facts.consumerHints,task-05 validator 经 facts 消费不经函数调用。
1069
+ const API_FACE_SECTION_RE = /接口|端点|api|rest/i
1070
+ const API_FACE_METHOD_RE = /(?:^|[^A-Za-z])(GET|POST|PUT|DELETE|PATCH)(?![A-Za-z])/
1071
+ // 路径样式 token:/ 起始、前邻非字母数字(日期 2026/09/17 的 /09 前是数字段不认),
1072
+ // 首字符字母数字/_/-/{,后续段字符含 {}/: 模板形态(/orders/{id}、/orders/:id 全串命中)
1073
+ const API_FACE_PATH_RE = /(?:^|[^A-Za-z0-9])(\/[A-Za-z0-9_\-{}][\w\-./{}:]*)/
1074
+ const API_FACE_DECLARED_RE = /本变更接口面[::]\s*(\d+)\s*端点/
1075
+
1076
+ /**
1077
+ * design.md 接口段 tolerant 解析(task-04 / D-005)。纯函数、本地正则零新依赖;输入统一
1078
+ * CRLF→LF 归一(parseRuntimeEndpointExcluded 同款,Windows/Linux/macOS 兼容)。
1079
+ * @param {string} designMd design.md 全文
1080
+ * @returns {{ endpoints: Array<{method: string, path: string, rowIdx: number}>,
1081
+ * declared: number|null, sectionHint: string[] }}
1082
+ */
1083
+ export function parseDesignApiTable(designMd) {
1084
+ const text = String(designMd || '').replace(/\r\n/g, '\n')
1085
+ const dm = text.match(API_FACE_DECLARED_RE)
1086
+ const declared = dm ? parseInt(dm[1], 10) : null
1087
+ const endpoints = []
1088
+ const sectionHint = []
1089
+ for (const sec of splitMdSections(text)) {
1090
+ if (!API_FACE_SECTION_RE.test(sec.heading)) continue
1091
+ sectionHint.push(sec.heading)
1092
+ let rowIdx = 0
1093
+ for (const line of sec.lines) {
1094
+ const t = line.trim()
1095
+ if (!t || t.startsWith('#')) continue
1096
+ const mm = t.match(API_FACE_METHOD_RE)
1097
+ if (!mm) continue // 缺方法列(有路径无方法 token)不认——双条件缺一不可
1098
+ const pm = t.match(API_FACE_PATH_RE)
1099
+ if (!pm) continue // 有方法无路径样式 token(说明行/纯文档行)不认
1100
+ rowIdx++
1101
+ endpoints.push({ method: mm[1], path: pm[1], rowIdx })
1102
+ }
1103
+ }
1104
+ return { endpoints, declared, sectionHint }
1105
+ }
1106
+
1107
+ // ── 消费端归类启发式(task-04 / D-006 / FR-05,仅 advisory 提示面)──
1108
+ // design 清单文件面路径段归类 web/mp/script:routes/pages/model 目录词 → web;mp/miniapp →
1109
+ // mp;scripts/test → script。归类只用于 task-05 的「有消费端未填子行」warning 提示(R-05:
1110
+ // 启发式假提示无害)——不做语义判定。路径段级匹配非裸子串('homepages.js' 文件名不命中
1111
+ // pages 段);mp 检查先于 web(mp/pages/index 小程序页归 mp 不归 web);NEW: 前缀剥除
1112
+ // (probe1-new-prefix-miss 同款)。
1113
+ const CONSUMER_WEB_SEGMENTS = new Set(['routes', 'pages', 'model'])
1114
+ const CONSUMER_MP_SEGMENT_RE = /^(?:mp|miniapp|miniprogram|weapp)$/i
1115
+ const CONSUMER_SCRIPT_SEGMENTS = new Set(['scripts', 'script', 'test', 'tests'])
1116
+ const CONSUMER_HINT_FILE_CAP = 20
1117
+ /**
1118
+ * 清单路径 → 消费端归类(task-04,导出供 task-05 经落盘/传参双通道消费)。
1119
+ * @param {string[]} paths design 清单文件路径(NEW: 前缀/CRLF 容忍)
1120
+ * @returns {Record<string, string[]>} 命中类别的支撑文件(键 ∈ web/mp/script,零命中 {})
1121
+ */
1122
+ export function classifyConsumerHints(paths) {
1123
+ const hints = {}
1124
+ const push = (kind, p) => {
1125
+ if (!hints[kind]) hints[kind] = []
1126
+ if (hints[kind].length < CONSUMER_HINT_FILE_CAP && !hints[kind].includes(p)) hints[kind].push(p)
1127
+ }
1128
+ for (const raw of paths || []) {
1129
+ const p = String(raw || '').replace(/^NEW:\s*/, '').split('\\').join('/')
1130
+ if (!p || p.startsWith('.sillyspec/')) continue
1131
+ const segs = p.toLowerCase().split('/')
1132
+ if (segs.some(s => CONSUMER_MP_SEGMENT_RE.test(s))) push('mp', p)
1133
+ else if (segs.some(s => CONSUMER_WEB_SEGMENTS.has(s))) push('web', p)
1134
+ else if (segs.some(s => CONSUMER_SCRIPT_SEGMENTS.has(s))) push('script', p)
1135
+ }
1136
+ return hints
1137
+ }
1138
+
293
1139
  /**
294
1140
  * 探针 8 主体:design 清单三面文件(Java/SQL/前端)→ 双根(主仓 ∪ worktree)∪ 跨仓注册仓根
295
1141
  * 读取 → 归一化比对。advisory:所有输出是「候选」不是结论(UI 本地态键/服务端填充列会自然
296
1142
  * 出现在差异里,agent 逐条复核——口径注记随渲染输出)。
297
- * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string }} args
1143
+ * @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string,
1144
+ * diffFiles?: {frontend?: string[], backend?: string[]}|null }} args
1145
+ * diffFiles(task-04 参数化预留,task-05 已接线):显式传入时优先(测试注入口)直接组装
1146
+ * direct-compare;缺省 null → 内部 collectProbe8DiffFiles 三态采集(worktree diff /
1147
+ * in-place diff / design-list 兜底)喂同一组装块,并落模式注记(design-list 态含
1148
+ * 「direct-compare 面可能不全」)与 designOnlyPaths advisory 行(详见函数尾接线块)。
298
1149
  * @returns {{applicable, backendFieldCount, feKeyCount, notNullCount, javaFileCount, sqlFileCount, feFileCount,
299
1150
  * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
300
- * contractCount: number, contractOrphans: Array<{fe, hint?}>, missingRequired: Array<{field, contract}>}}
1151
+ * contractCount: number, contractOrphans: Array<{fe, hint?}>, missingRequired: Array<{field, contract}>,
1152
+ * directCompare?: {driftWarnings: Array<{file, line: number|null, field}>,
1153
+ * missingRequiredWarnings: Array<{endpoint, method, field, frontendFiles: string[]}>,
1154
+ * escapeHatchCount: number, nonJavaSkipCount: number}}}
301
1155
  */
302
- export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName }) {
1156
+ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName, diffFiles = null }) {
303
1157
  const out = {
304
1158
  applicable: false, backendFieldCount: 0, feKeyCount: 0, notNullCount: 0,
305
1159
  javaFileCount: 0, sqlFileCount: 0, feFileCount: 0,
@@ -465,6 +1319,57 @@ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeNam
465
1319
  out.backendFieldCount = backendFields.size
466
1320
  out.feKeyCount = feKeys.size
467
1321
  out.notNullCount = notNullCols.size
1322
+
1323
+ // ── probe8 diff 源接线(task-05,D-001@v1):direct-compare 维度的文件面从 design 清单
1324
+ // 切换为 collectProbe8DiffFiles 三态采集(worktree diff / in-place diff / design-list 兜底),
1325
+ // 分类面(frontend/backend)喂 task-04 组装块;上文 design 清单三面/契约面对账(readEntry
1326
+ // 消费 detailed 的既有逻辑 + contractOrphans/missingRequired)零改动——diff 源只替换
1327
+ // direct-compare 维度文件面,不替换契约面文件面。外部 diffFiles 显式传入时优先(测试注
1328
+ // 入口,跳过采集零注记)。采集器 notes 全量转发(三态模式注记/跨仓失败注记 fail-visible);
1329
+ // design-list 态补「direct-compare 面可能不全」风险注记;designOnlyPaths 非空落 advisory
1330
+ // 行(design 声明但 diff 面无——不参与对账不阻断,供人工复核)。跨仓 diff 面产物是仓根相对
1331
+ // 路径且无 repo 标记,readEntry 按主仓双根读不到即跳过(fail-soft,直比面跨仓缺位是已知
1332
+ // 边界;主仓恰有同名路径时读主仓文件,advisory 面可接受)。──
1333
+ let diffFace = diffFiles
1334
+ if (!diffFace) {
1335
+ const collected = collectProbe8DiffFiles({ cwd, changeName, specBase, repoKeys: [...registry.keys()] })
1336
+ for (const n of collected.notes) out.notes.push(n)
1337
+ if (collected.source === 'design-list') {
1338
+ out.notes.push('文件源=design 清单(diff 不可用)——direct-compare 面可能不全')
1339
+ }
1340
+ if (collected.designOnlyPaths.length > 0) {
1341
+ out.notes.push(`design 声明但 diff 无的路径(advisory,不阻断):${collected.designOnlyPaths.join('、')}`)
1342
+ }
1343
+ diffFace = { frontend: collected.frontend, backend: collected.backend }
1344
+ }
1345
+
1346
+ // ── direct-compare 组装(task-04 参数化,task-05 接线):diffFace(外部注入或内部采集,
1347
+ // collectProbe8DiffFiles 产物形态 {frontend, backend} 路径数组)时组装代码级直比面挂
1348
+ // out.directCompare(渲染经 renderProbe8Lines 尾部子段消费);两数组全缺 → 键恒缺省。
1349
+ // 读取复用 readEntry(主仓∪worktree 双根 + 跨仓注册,fail-visible 注记);二趟类型解析
1350
+ // wtRoot/src→cwd/src 串行 miss-chain(createTypeResolver 各自 fail-soft,零新增 IO 边)。──
1351
+ if (diffFace && (Array.isArray(diffFace.frontend) || Array.isArray(diffFace.backend))) {
1352
+ const frontendByFile = []
1353
+ for (const p of Array.isArray(diffFace.frontend) ? diffFace.frontend : []) {
1354
+ const content = readEntry({ path: p })
1355
+ if (content == null) continue
1356
+ frontendByFile.push({ filePath: p, extract: extractFrontendPayloadFields(p, content) })
1357
+ }
1358
+ const backendFiles = []
1359
+ for (const p of Array.isArray(diffFace.backend) ? diffFace.backend : []) {
1360
+ const content = readEntry({ path: p })
1361
+ if (content == null) continue
1362
+ backendFiles.push({ path: p, content })
1363
+ }
1364
+ const resolvers = [wtRoot, cwd].filter(Boolean).map(b => createTypeResolver(join(b, 'src')))
1365
+ const be = extractBackendFields(backendFiles, (tn) => {
1366
+ for (const r of resolvers) { const c = r(tn); if (c) return c }
1367
+ return null
1368
+ })
1369
+ out.directCompare = comparePayloadFields(frontendByFile, be.endpoints, be.backendAllFields, {
1370
+ escapeHatchCount: be.escapeHatchCount, nonJavaSkipCount: be.nonJavaSkipCount,
1371
+ })
1372
+ }
468
1373
  return out
469
1374
  }
470
1375
 
@@ -1258,7 +2163,20 @@ export function runVerifyProbes({ cwd, changeName, specDir = null }) {
1258
2163
  probe9.notes = [`探针 9 执行失败(fail-soft 跳过):${e && e.message ? e.message : e}`]
1259
2164
  }
1260
2165
 
1261
- return { probe1, probe3, probe5, probe6, probe7, probe8, probe9 }
2166
+ // ── 接口面 + 消费端归类(task-04 / FR-05 / D-005~D-007):design.md 接口段 tolerant
2167
+ // 解析 + 清单消费端归类——骨架「## 接口验证覆盖矩阵」段预填与 facts 落盘
2168
+ // (backfillFactsFromMdAndTests)共用同一解析器(单一产物源,不各读各的)。fail-soft:
2169
+ // design 缺失/解析异常 → 空面(骨架渲染「无接口面」注记,validator 判 N=0 走零行为注记)。──
2170
+ let apiFace = { endpoints: [], declared: null, sectionHint: [] }
2171
+ let consumerHints = {}
2172
+ try {
2173
+ if (existsSync(designPath)) {
2174
+ apiFace = parseDesignApiTable(readFileSync(designPath, 'utf8'))
2175
+ consumerHints = classifyConsumerHints(detailed.map(e => String(e.path)))
2176
+ }
2177
+ } catch { /* design 读/解析异常 → 空面(fail-soft) */ }
2178
+
2179
+ return { probe1, probe3, probe5, probe6, probe7, probe8, probe9, apiFace, consumerHints }
1262
2180
  }
1263
2181
 
1264
2182
  /**
@@ -1373,7 +2291,12 @@ export function renderVerifyProbesReport(result) {
1373
2291
  * @param {{applicable: boolean, backendFieldCount?: number, feKeyCount?: number, notNullCount?: number,
1374
2292
  * javaFileCount?: number, sqlFileCount?: number, feFileCount?: number,
1375
2293
  * mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
1376
- * contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}>}} p8
2294
+ * contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}>,
2295
+ * directCompare?: {driftWarnings: Array<{file, line: number|null, field}>,
2296
+ * missingRequiredWarnings: Array<{endpoint, method, field, frontendFiles: string[]}>,
2297
+ * escapeHatchCount: number, nonJavaSkipCount: number}}} p8
2298
+ * directCompare(task-04):runProbe8PayloadParity 在外部传入 diffFiles 时组装的代码级直比
2299
+ * 面——缺失(存量调用/兜底形态)时子段 if 块静默跳过,零回归。
1377
2300
  * @returns {string[]} 行数组(含段标题)
1378
2301
  */
1379
2302
  function renderProbe8Lines(p8) {
@@ -1407,6 +2330,10 @@ function renderProbe8Lines(p8) {
1407
2330
  L.push('- ✅ 载荷字段面零疑似差异(归一化覆盖 + NOT NULL 全见)')
1408
2331
  }
1409
2332
  for (const n of p8.notes || []) L.push(`- ℹ️ ${n}`)
2333
+ // direct-compare 子段(task-04):代码级直比面(漂移嫌疑/必填漏发嫌疑,advisory 独立面——
2334
+ // 不进 errors/warnings)。独立 if 块,删除即整体回退;p8.directCompare 缺失(存量调用/兜底
2335
+ // 形态)时静默跳过零回归。明细行防撞 verify-postcheck PROBE8 锚点(行首字面前缀不同)。
2336
+ if (p8.directCompare) L.push(...renderDirectCompareSection(p8.directCompare))
1410
2337
  return L
1411
2338
  }
1412
2339
 
@@ -1442,6 +2369,54 @@ function renderProbe9Lines(p9) {
1442
2369
  return L
1443
2370
  }
1444
2371
 
2372
+ // ── 接口验证覆盖矩阵段渲染(task-04 / FR-05 / D-005~D-007)──
2373
+ // 骨架新写路径(generateVerifyResultSkeleton)与缺段补齐(ensureApiCoverageMatrixSection)
2374
+ // 共用单一实现——renderProbe7Lines 先例。不动 probe7 既有矩阵(独立 ## 章节并行存在,口径
2375
+ // 注记互指区分,D-009 非目标 / R-07)。预填口径同 probe7:CLI 机械预填、agent 逐格复核改写。
2376
+ const API_MATRIX_HEADING = '## 接口验证覆盖矩阵'
2377
+ // 写端点集合(D-007/FR-06 表间完备性 advisory 数据面):method ∈ 此集的端点须在 design
2378
+ // 权限矩阵段有对应行或显式豁免(「无权限约束」)——warning 计算归 task-05,此处只定集合。
2379
+ const API_MATRIX_WRITE_METHODS = new Set(['POST', 'PUT', 'DELETE', 'PATCH'])
2380
+
2381
+ /**
2382
+ * 渲染「## 接口验证覆盖矩阵」段(task-04)。
2383
+ * 三态:解析有端点 → 逐端点预填行(判定列 `<待填:四选一>` 占位,同 probe7 预填口径供 agent
2384
+ * 复核);解析零行有声明 → 声明占位行「本变更接口面:<N> 端点(agent 声明)」(D-005 零解析
2385
+ * 降级,对账分母=声明数);零解析零声明 → 「无接口面」注记行(非判级 critical 变更零行为
2386
+ * 注记即可——判级 critical 的 error 拦截归 task-05 validator)。段尾 advisory 占位注记
2387
+ * (消费面/表间完备性,warning 计算归 task-05,本段只留位)。
2388
+ * @param {{endpoints?: Array<{method: string, path: string, rowIdx: number}>, declared?: number|null}|null} apiFace
2389
+ * @returns {string[]} 行数组(含段标题;调用方自理前后空行)
2390
+ */
2391
+ function renderApiCoverageMatrixLines(apiFace) {
2392
+ const face = apiFace && typeof apiFace === 'object' ? apiFace : null
2393
+ const endpoints = face && Array.isArray(face.endpoints) ? face.endpoints : []
2394
+ const declared = face && typeof face.declared === 'number' ? face.declared : null
2395
+ const L = [`${API_MATRIX_HEADING} [层:人工判断——CLI 预填复核]`]
2396
+ L.push('<!-- 口径注记(与探针 7 互指,R-07):探针 7 = 验收项 × 测试承接面(每条 acceptance 由哪些测试承接);本矩阵 = 接口端点 × 验证用例面(design 接口段每个端点由哪些验证用例/冒烟步骤覆盖)——两者并排互补,双矩阵并行存在。端点集来自 design.md 接口段 tolerant 解析(parseDesignApiTable:段头宽收 + 方法/路径双条件),预填≠结论,agent 逐行复核。判定枚举(四选一):covered / partial / uncovered / non-testable。 -->')
2397
+ L.push('<!-- 预填说明:端点行由 CLI 机械预填,判定/用例依据 ID/结果/证据由 agent 逐格填写——用例依据 ID 锚点五形态:design接口表#METHOD /path、权限矩阵[角色×动作]、契约表@行标识、DDL@列名、载荷@构造点路径(须真实命中对应表/段,防空指)。 -->')
2398
+ L.push('<!-- 文法注释:子行 = 端点行下一行、两空格缩进、以「↳ <消费端>:」前缀书写(消费端细分承接面,不计矩阵行账);探索行 = 判定 uncovered 且证据列含 [探索] 标记(探索性验证不算覆盖)。 -->')
2399
+ if (endpoints.length === 0 && declared === null) {
2400
+ L.push('- 无接口面(design 接口段解析零端点且无「本变更接口面:N 端点」声明行)——本变更若实际触碰接口,先补 design 接口段表格或声明行,再重跑 `verify-probes --init` 刷新本段;判级 critical 的零面拦截归 validator')
2401
+ return L
2402
+ }
2403
+ if (endpoints.length > 0 && declared !== null && declared !== endpoints.length) {
2404
+ L.push(`<!-- 声明与解析并存(声明 ${declared} 端点 / 解析 ${endpoints.length} 端点)——以解析为准,差异需复核(design 接口段与声明行不同步的漂移信号) -->`)
2405
+ }
2406
+ L.push('| 端点 | 判定 | 用例依据 ID | 结果 | 证据 |')
2407
+ L.push('|---|---|---|---|---|')
2408
+ if (endpoints.length > 0) {
2409
+ for (const ep of endpoints) {
2410
+ L.push(`| ${mdEscapeCell(`${ep.method} ${ep.path}`, 160)} | <待填:四选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
2411
+ }
2412
+ } else {
2413
+ L.push(`| 本变更接口面:${declared} 端点(agent 声明) | <待填:四选一> | <待填:用例 ID> | <待填> | <待填:锚点> |`)
2414
+ L.push('<!-- 解析零行降级(D-005):接口面以 agent 声明为准(对账分母=声明数);声明与实际不符时补 design 接口段表格后重跑 --init 刷新本段 -->')
2415
+ }
2416
+ L.push('<!-- advisory 尾注(warning 计算归 validator,本段只留位):有消费端未填子行的端点将列于此(advisory——消费端归类=design 清单启发式,数据面 facts.consumerHints);写端点(POST/PUT/DELETE/PATCH)未在权限矩阵段声明的将列于此(advisory——补行或显式豁免「无权限约束」,数据面 facts.apiFace.writeEndpoints;表缺行会让派生框架继承你的洞) -->')
2417
+ return L
2418
+ }
2419
+
1445
2420
  /**
1446
2421
  * 从 runVerifyProbes 结果构造 verify-facts.json 机器底稿对象(P3b 可复跑审计底稿)。
1447
2422
  * 命令行统一 `sillyspec verify-probes --change <name>`;指标 fail-soft——字段不可得时少列
@@ -1604,7 +2579,7 @@ const HANDOVER_DEFAULT_SEVERITY = (type) =>
1604
2579
  // 降级理由文法(X-02):(降级:<理由>,依据 <file:line 或 D-xxx>)——全角括号/冒号/逗号,
1605
2580
  // 依据锚点 D-xxx 或 file:line。JS 正则不转义全角括号(知识库坑)。blocking→advisory 降级
1606
2581
  // 未命中本文法 → 仍按 blocking(fail-closed,防「全标 advisory」钻空——D-005@v2)。
1607
- const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+|[^\s,):]+:\d+))/
2582
+ const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+(?:@\d+)?|[^\s,):]+:\d+))/
1608
2583
  // severity 解析:显式 blocking 优先(混写保守);显式 advisory 对 blocking 缺省类型构成降级,
1609
2584
  // 理由文法在第 4 列或条件列任一命中即认(骨架第 4 列定型前 producer 宽收两侧;抽查面归
1610
2585
  // checkProbeConsistency 消费侧后续任务);两枚举都不含 → 缺省映射。
@@ -1634,6 +2609,8 @@ export function parseHandoverRows(md) {
1634
2609
  const condition = row[3].trim()
1635
2610
  if (type === '类型' || /^-{2,}$/.test(type.replace(/\|/g, ''))) continue // 表头/分隔行
1636
2611
  if (type.startsWith('<') || item.startsWith('<')) continue // 骨架占位行
2612
+ if (type === '无' || item === '无') continue // 裸「无」行=骨架指引的空表占位(结论=PASS/FAIL 写「无」)——
2613
+ // 真「无可移交」的合法表达是矩阵干净+零有效行,不是一行无;精确相等防误杀「无明确去向」类真条目
1637
2614
  if (!type || !item) continue
1638
2615
  const normalizedType = type.toLowerCase().replace(/[\s_]+/g, '-')
1639
2616
  items.push({
@@ -1687,16 +2664,20 @@ export function parseDbScriptDeclarations(md) {
1687
2664
  // 退役判据:批次 C commands.smoke 落地后由 smoke 回执一票判定「集成实测已跑」——起服务冒烟
1688
2665
  // 即跨层实测,不得默认 build 误拦金路径);unit=单测 runner 直跑(JUnitCore/node --test/
1689
2666
  // 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) {
2667
+ //
2668
+ // 来源标记直判(2026-09-17-api-coverage-smoke task-02 / Grill B-1 修正,FR-03):条目 source
2669
+ // 标记 'cli-noai-smoke'(parseEvidenceSlots additive 第五字段——本文件 ensureSmokeReceiptSection
2670
+ // 注入的机器段行尾注回填)→ 直判 cross-layer,**优先于命令词正则分类**——修正点在标记识别
2671
+ // 而非命令词增补:node scripts/smoke.mjs / bash smoke.sh / python smoke.py 等脚本形态在既有
2672
+ // 正则下全判 build 会误拦金路径,且脚本形态词表不可枚举(RECEIPT_CROSS_LAYER_RE 正则族
2673
+ // 零词变动)。对齐目标:change-risk-profile.js classifyReceiptSourceTag(sourceMark 参数与
2674
+ // CLI_SMOKE_SOURCE_MARK 常量)——**任一侧增改标记族必须双侧同步**(G-3 铁律)。
2675
+ // (正则族单点收敛 ql-20260918-001:RECEIPT_CROSS_LAYER_RE/SMOKE_RECEIPT_SOURCE_MARK
2676
+ // 从 change-risk-profile.js 导入(RECEIPT_SOURCE_CROSS_LAYER_RE/CLI_SMOKE_SOURCE_MARK 别名)——
2677
+ // 消灭双文件逐词同步的口径漂移面(G-3 根因)。
2678
+ /** CLI 机器段来源标记(与 change-risk-profile.js CLI_SMOKE_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
2679
+ function classifyReceiptCommandSource(command, sourceMark) {
2680
+ if (sourceMark === SMOKE_RECEIPT_SOURCE_MARK) return 'cross-layer'
1700
2681
  const cmd = String(command || '')
1701
2682
  if (RECEIPT_CROSS_LAYER_RE.test(cmd)) return 'cross-layer'
1702
2683
  if (RECEIPT_UNIT_RE.test(cmd)) return 'unit'
@@ -1734,7 +2715,9 @@ function judgeIntegrationRan(factsPath, runtimeEvidence) {
1734
2715
  const scanRan = Boolean(record && record.testResult && record.testResult.status && record.testResult.status !== 'skipped')
1735
2716
  if (scanRan && ['full', 'module', 'evidence-auto'].includes(strategy)) return { ran: 'ran', notes: [] }
1736
2717
  const receipts = Array.isArray(runtimeEvidence) ? runtimeEvidence : []
1737
- if (receipts.some(r => classifyReceiptCommandSource(r && r.command) === 'cross-layer')) {
2718
+ // 条目 source 标记透传(task-02 Grill B-1):source='cli-noai-smoke' 直判 cross-layer——
2719
+ // 机器段命令(node scripts/smoke.mjs 等脚本形态)不再落 build 误拦金路径。
2720
+ if (receipts.some(r => classifyReceiptCommandSource(r && r.command, r && r.source) === 'cross-layer')) {
1738
2721
  return { ran: 'ran', notes: [] }
1739
2722
  }
1740
2723
  const notes = []
@@ -1778,6 +2761,22 @@ function countMatrixPartialRows(md) {
1778
2761
  }
1779
2762
 
1780
2763
  export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResult = null, conclusion = null }) {
2764
+ // smoke 机器段 ensure(2026-09-17-api-coverage-smoke task-02,挂 backfill 合并时点——
2765
+ // quality-scan step 6 noAI 亲跑落记录先于本步,读得到实录):read-modify-write 盘上 md
2766
+ // 最新态(不基于 verifyMd 快照写回,防覆盖多 agent 并发改动);注入/态更新成功后解析源
2767
+ // 切盘上最新内容——机器段行立即参与 slots 解析,judgeIntegrationRan 同轮即吃到
2768
+ // source 标记直判 cross-layer。ensure 落盘失败/无记录 fail-soft,不阻断 facts 回填。
2769
+ let verifyMdText = verifyMd
2770
+ try {
2771
+ const ensureDir = dirname(factsPath)
2772
+ const ensured = ensureSmokeReceiptSection(join(ensureDir, 'verify-result.md'), {
2773
+ specBase: dirname(dirname(ensureDir)),
2774
+ changeName: basename(ensureDir),
2775
+ })
2776
+ if (ensured.changed) {
2777
+ verifyMdText = readFileSync(join(ensureDir, 'verify-result.md'), 'utf8')
2778
+ }
2779
+ } catch { /* ensure 异常 fail-soft,按传入快照继续回填 */ }
1781
2780
  let facts = null
1782
2781
  try { facts = JSON.parse(readFileSync(factsPath, 'utf8')) } catch { /* 缺失见下 */ }
1783
2782
  // 只固化既有底稿,不无中生有(2026-09-08-ir-verify-facts 接线实证):无 facts 的存量变更
@@ -1790,7 +2789,7 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
1790
2789
  if (facts.schemaVersion !== FACTS_SCHEMA_VERSION) {
1791
2790
  facts = { ...facts, schemaVersion: FACTS_SCHEMA_VERSION, probes: (facts && facts.probes) || {} }
1792
2791
  }
1793
- const slots = parseEvidenceSlots(verifyMd || '')
2792
+ const slots = parseEvidenceSlots(verifyMdText || '')
1794
2793
  // conclusion 由调用方传 stage-contract.extractVerifyConclusionSlot(verifyMd) 的结果
1795
2794
  // (避免 verify-probes → stage-contract 静态 import 的潜在环,分层单向)
1796
2795
  const conclusionSlot = conclusion || null
@@ -1804,14 +2803,15 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
1804
2803
  if (slots.hasReceiptSlot && slots.runtimeEvidence.length > 0) {
1805
2804
  facts.runtimeEvidence = slots.runtimeEvidence
1806
2805
  }
1807
- // 移交项结构化回填(handover-only-in-prose):有有效行 → facts.handover 落盘(机器可读);
2806
+ // 移交项结构化回填(handover-only-in-prose):facts.handover **恒落盘**(零行= {count:0, items:[]}——
2807
+ // 2026-09-18-fr-index-l1 verify 期实证死锁修复:原「有行才写」使 零移交+结论 PASS 被
2808
+ // evaluatePassEligibility 条件② handoverFieldMissing fail-closed 拦死(PASS+真零移交结构性不可能);
2809
+ // eligibility 测试的 CLEAN_FACTS 本就是 count:0 形态——字段在场零行=净,本行只是对齐既有契约);
1808
2810
  // PASS WITH NOTES 零有效行 → advisory 警告(不阻断——存量 PASS WITH NOTES 无此章节是常态,
1809
2811
  // 渐进采纳)。EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1,清单化才有
1810
2812
  // 「谁兜、怎么复跑」的可追溯面。
1811
- const handoverItems = parseHandoverRows(verifyMd || '')
1812
- if (handoverItems.length > 0) {
1813
- facts.handover = { count: handoverItems.length, items: handoverItems }
1814
- }
2813
+ const handoverItems = parseHandoverRows(verifyMdText || '')
2814
+ facts.handover = { count: handoverItems.length, items: handoverItems }
1815
2815
  if (conclusionSlot === 'PASS WITH NOTES' && handoverItems.length === 0) {
1816
2816
  console.warn('⚠️ 结论=PASS WITH NOTES 但「移交项(结构化)」章节零有效行——正文叙述的移交项(环境阻断复跑条件/人工验收步骤/待执行脚本)请结构化进 ## 移交项(结构化) 表格(类型枚举 env-blocked/manual-acceptance/db-script/other),避免移交项只活在 prose 里没人兜(2026-09-15 EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1)。')
1817
2817
  }
@@ -1820,12 +2820,45 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
1820
2820
  // testCheckResult 时点(task-02 validator 在 runValidators 时点消费,已就位);误挂二次
1821
2821
  // 回填的 testCheckResult 分支则消费时点读不到、恒误拦。判定输入自推导自读(quality-scan
1822
2822
  // 记录按 specBase 推导 / 矩阵经动态绑定),不依赖调用方传参。
1823
- facts.dbScriptDeclarations = parseDbScriptDeclarations(verifyMd || '')
1824
- facts.runtimeEndpointExcluded = parseRuntimeEndpointExcluded(verifyMd || '')
2823
+ facts.dbScriptDeclarations = parseDbScriptDeclarations(verifyMdText || '')
2824
+ facts.runtimeEndpointExcluded = parseRuntimeEndpointExcluded(verifyMdText || '')
1825
2825
  const integration = judgeIntegrationRan(factsPath, slots.runtimeEvidence)
1826
2826
  facts.integrationRan = integration.ran
1827
2827
  for (const note of integration.notes) console.warn(note)
1828
- facts.matrixPartialRows = countMatrixPartialRows(verifyMd || '')
2828
+ facts.matrixPartialRows = countMatrixPartialRows(verifyMdText || '')
2829
+ // ── facts.apiFace / facts.consumerHints(2026-09-17-api-coverage-smoke task-04 / FR-05 /
2830
+ // D-005~D-007 producer 侧)──X-08 主路径无条件产出(同上口径,不依赖 testCheckResult
2831
+ // 时点):apiFace = design 接口段 tolerant 解析面(endpoints/declared + writeEndpoints
2832
+ // 子集=表间完备性数据面),consumerHints = 清单消费端归类启发式(web/mp/script,仅
2833
+ // warning 提示面)。数据通道=落盘即消费(stage-contract 零 import 纪律,分层单向)——
2834
+ // task-05 validator 经 facts 读,不经函数调用。design.md 缺失 → 空面落盘(判定面稳定,
2835
+ // validator 判 N=0 走零行为注记);与 runVerifyProbes 同一解析器(单一产物源)。
2836
+ {
2837
+ const changeDir = dirname(factsPath)
2838
+ const designPath = join(changeDir, 'design.md')
2839
+ let designText = null
2840
+ if (existsSync(designPath)) {
2841
+ try { designText = readFileSync(designPath, 'utf8') } catch { designText = null }
2842
+ }
2843
+ const face = parseDesignApiTable(designText || '')
2844
+ facts.apiFace = {
2845
+ endpoints: face.endpoints,
2846
+ declared: face.declared,
2847
+ writeEndpoints: face.endpoints.filter(e => e && API_MATRIX_WRITE_METHODS.has(e.method)),
2848
+ }
2849
+ let designPaths = []
2850
+ if (designText !== null) {
2851
+ try { designPaths = parseFileChangeListDetailed(designPath).map(e => String(e.path)) } catch { designPaths = [] }
2852
+ }
2853
+ facts.consumerHints = classifyConsumerHints(designPaths)
2854
+ }
2855
+ // ── facts.smokeRan(2026-09-17-api-coverage-smoke task-03 / FR-02 / D-002@v1 producer 侧)──
2856
+ // X-08 主路径无条件产出(同上四字段口径,不依赖 testCheckResult 时点):唯一事实源 =
2857
+ // quality-scan 记录 smokeResult 段(judgeSmokeRan 五边界态封闭),不受 verify-result.md
2858
+ // 篡改影响(R-06 双源兜底)。消费方 = evaluatePassEligibility 第五条件(smoke-not-run)。
2859
+ const smokeRanJudge = judgeSmokeRan(factsPath)
2860
+ facts.smokeRan = smokeRanJudge.value
2861
+ for (const note of smokeRanJudge.notes) console.warn(note)
1829
2862
  let testsBackfilled = false
1830
2863
  if (testCheckResult && testCheckResult.status && testCheckResult.status !== 'skipped') {
1831
2864
  facts.tests = {
@@ -1931,6 +2964,235 @@ export function ensureAcceptanceMatrixSection(mdPath, probe7) {
1931
2964
  return { added: true }
1932
2965
  }
1933
2966
 
2967
+ /**
2968
+ * 接口验证覆盖矩阵段幂等补齐(task-04;ensureAcceptanceMatrixSection 先例形态):verify-
2969
+ * result.md 已存在但缺「## 接口验证覆盖矩阵」段时补骨架段。幂等口径(renderProbe7Lines
2970
+ * 补段同款):段已在场(agent 已填/未填)一律不触碰——预填只在骨架生成与缺段补齐两条新写
2971
+ * 路径生效,agent 已填内容永不被覆盖;二跑零改动。插入位置:「## 探针结果」章节之后(下一
2972
+ * ## 标题前,保持骨架章节序),无探针章则文末追加(旧格式/手写正文)。
2973
+ * @param {string} mdPath verify-result.md 路径
2974
+ * @param {{endpoints?: Array<{method: string, path: string, rowIdx: number}>, declared?: number|null}|null} apiFace
2975
+ * @returns {{ added: boolean, reason?: string }}
2976
+ */
2977
+ export function ensureApiCoverageMatrixSection(mdPath, apiFace) {
2978
+ let text
2979
+ try { text = readFileSync(mdPath, 'utf8') } catch { return { added: false, reason: 'verify-result.md 不存在或不可读' } }
2980
+ const normalized = text.replace(/\r\n/g, '\n')
2981
+ if (/^## 接口验证覆盖矩阵/m.test(normalized)) return { added: false, reason: '接口验证覆盖矩阵段已在场' }
2982
+ const blockLines = [...renderApiCoverageMatrixLines(apiFace), '']
2983
+ try {
2984
+ const lines = normalized.split('\n')
2985
+ const probeIdx = lines.findIndex(l => /^## 探针结果/.test(l))
2986
+ if (probeIdx === -1) {
2987
+ writeFileSync(mdPath, normalized.replace(/\n?$/, '\n') + '\n' + blockLines.join('\n'))
2988
+ } else {
2989
+ // 探针结果章终点 = 下一个 ## 级标题行(矩阵段紧随其后,与骨架章节序一致)
2990
+ let end = lines.length
2991
+ for (let i = probeIdx + 1; i < lines.length; i++) {
2992
+ if (/^## /.test(lines[i])) { end = i; break }
2993
+ }
2994
+ lines.splice(end, 0, ...blockLines)
2995
+ writeFileSync(mdPath, lines.join('\n'))
2996
+ }
2997
+ } catch (e) {
2998
+ console.warn(`⚠️ 接口验证覆盖矩阵段补齐落盘失败(fail-soft,不阻断): ${e && e.message ? e.message : e}`)
2999
+ return { added: false, reason: '落盘失败' }
3000
+ }
3001
+ return { added: true }
3002
+ }
3003
+
3004
+ // ============ smoke 回执槽机器段(2026-09-17-api-coverage-smoke task-02 / FR-03,design §3) ============
3005
+ //
3006
+ // 唯一事实源 = .runtime/verify-quality-scan-<change>.json 的 smokeResult 段(task-01 契约,
3007
+ // R-06 双源兜底——md 被篡改不影响 facts 推导);本组函数把该记录 ensure 式渲染成
3008
+ // verify-result.md「## 集成验证回执」槽内 CLI 拥有的机器段行(source: cli-noai-smoke 标注
3009
+ // 识别),agent 只可追加段、不可改写机器段(改写/删除由 verify-postcheck
3010
+ // checkProbeConsistency 回执槽一致性对比打回,Grill #6 / R-06)。
3011
+ /** 机器段行首锚(ensure 幂等识别——claim 钦定文案;单行管道与多行 YAML 双形态首行均命中。
3012
+ * 注意不可用 \b 锚中文词尾:CJK 不属 \w,「亲跑」后与「 |」之间无 word 边界恒 false(实证
3013
+ * 幂等失效重复注入)——改前瞻断言(后随管道分隔或行尾)。 */
3014
+ const SMOKE_MACHINE_CLAIM_RE = /^- claim: commands\.smoke CLI 亲跑(?=\s*[||]|$)/
3015
+ /** 缺态注释行(态捕获 m[1],not-configured / not-ran 两态——非回执行形态,parseEvidenceSlots 不收取) */
3016
+ const SMOKE_ABSENT_LINE_RE = /^<!--\s*smoke 机器段缺态:(not-configured|not-ran)\b[^>]*source: cli-noai-smoke[^>]*-->$/
3017
+
3018
+ /**
3019
+ * 读 quality-scan 记录的 smokeResult 段(路径规则同 judgeIntegrationRan:specBase/
3020
+ * .runtime/verify-quality-scan-<changeName>.json)。共享读取点——ensure 注入与 postcheck
3021
+ * 一致性对比同源取数,不各读各的。记录缺失/损坏/无 smokeResult 段 → null。
3022
+ */
3023
+ export function readSmokeResultRecord(specBase, changeName) {
3024
+ try {
3025
+ const rec = JSON.parse(readFileSync(join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`), 'utf8'))
3026
+ if (rec && rec.schemaVersion === 1 && rec.source === 'cli-noai'
3027
+ && rec.smokeResult && typeof rec.smokeResult === 'object') {
3028
+ return rec.smokeResult
3029
+ }
3030
+ } catch { /* 记录缺失/损坏 → null */ }
3031
+ return null
3032
+ }
3033
+
3034
+ /**
3035
+ * 由 smokeResult 记录推导机器段目标态(ensure 与 postcheck 共享口径,不二算):
3036
+ * 'passed'(configured 且亲跑绿)| 'not-configured'(未配置)| 'not-ran'(配置但
3037
+ * failed/超时——task-01 契约 timeout 归 status='failed')。
3038
+ */
3039
+ export function deriveSmokeSectionState(smoke) {
3040
+ if (!smoke || typeof smoke !== 'object') return null
3041
+ if (smoke.configured === false) return 'not-configured'
3042
+ return smoke.status === 'passed' ? 'passed' : 'not-ran'
3043
+ }
3044
+
3045
+ /**
3046
+ * facts.smokeRan 判定(2026-09-17-api-coverage-smoke task-03 / FR-02 / D-002@v1):
3047
+ * 唯一事实源 = quality-scan 记录 smokeResult 段(readSmokeResultRecord 共享读取点,R-06
3048
+ * 双源兜底——不受 verify-result.md 篡改影响;judgeIntegrationRan 同款自读先例:specBase/
3049
+ * changeName 自 factsPath 目录上推,不依赖调用方传参)。五边界态封闭:
3050
+ * configured=false(未配置键 / commands.smoke: unavailable / local.yaml 不可读)→
3051
+ * not-configured;status='passed'(exit 0)→ ran;status='failed'(exit 非 0/超时——
3052
+ * task-01 契约 timeout 归 failed,失败也是未通过)→ not-ran;记录在场但无 smokeResult
3053
+ * 段(升级过渡期存量记录)→ not-ran + fail-open 注记;记录缺失/损坏 → not-ran +
3054
+ * fail-open 注记(X-01 口径,不阻断——出路=重跑质量扫描步或降级 NOTES)。
3055
+ * @param {string} factsPath verify-facts.json 路径(<specBase>/changes/<name>/verify-facts.json)
3056
+ * @returns {{ value: 'ran'|'not-ran'|'not-configured', notes: string[] }}
3057
+ */
3058
+ function judgeSmokeRan(factsPath) {
3059
+ const changeDir = dirname(factsPath)
3060
+ const changeName = basename(changeDir)
3061
+ const specBase = dirname(dirname(changeDir))
3062
+ const recordPath = join(specBase, '.runtime', `verify-quality-scan-${changeName}.json`)
3063
+ const smoke = readSmokeResultRecord(specBase, changeName)
3064
+ if (smoke) {
3065
+ if (smoke.configured === false) return { value: 'not-configured', notes: [] }
3066
+ return { value: smoke.status === 'passed' ? 'ran' : 'not-ran', notes: [] }
3067
+ }
3068
+ // readSmokeResultRecord null 两缺因区分注记:记录在场但无 smokeResult 段(升级过渡期
3069
+ // 存量记录)vs 记录缺失/损坏——同判 not-ran,注记各自点名(X-01 fail-open,不阻断)。
3070
+ let recordPresent = false
3071
+ try {
3072
+ const rec = JSON.parse(readFileSync(recordPath, 'utf8'))
3073
+ if (rec && rec.schemaVersion === 1 && rec.source === 'cli-noai') recordPresent = true
3074
+ } catch { recordPresent = false }
3075
+ return {
3076
+ value: 'not-ran',
3077
+ notes: [recordPresent
3078
+ ? `ℹ️ quality-scan 记录在场但无 smokeResult 段(${recordPath},升级过渡期存量记录)——facts.smokeRan 按 not-ran 判定(X-01 fail-open 注记,不阻断;出路=重跑质量扫描步或降级 NOTES)`
3079
+ : `ℹ️ quality-scan 实测记录缺失(${recordPath})——facts.smokeRan 按 not-ran 判定(X-01 fail-open 注记,不阻断;--done 亲测替代扫描的场景记录时序不可得,出路=重跑质量扫描步或降级 NOTES)`],
3080
+ }
3081
+ }
3082
+
3083
+ /**
3084
+ * 扫描 verify-result.md 回执槽内 CLI 拥有的 smoke 机器段标记(ensure 幂等判定与
3085
+ * verify-postcheck 一致性对比共享解析点):①机器段回执条目 = parseEvidenceSlots 收取且
3086
+ * source='cli-noai-smoke' 的条目(含 agent 冒充行——冒充也是打回面);②缺态注释行态。
3087
+ * @param {string} mdText verify-result.md 全文(CRLF 归一内部处理)
3088
+ * @returns {{ machineEntries: Array<{claim,command,exitCode,logPath,source}>, absentState: 'not-configured'|'not-ran'|null }}
3089
+ */
3090
+ export function scanSmokeReceiptSection(mdText) {
3091
+ const text = String(mdText || '').replace(/\r\n/g, '\n')
3092
+ let machineEntries = []
3093
+ try {
3094
+ machineEntries = (parseEvidenceSlots(text).runtimeEvidence || [])
3095
+ .filter(e => e && e.source === SMOKE_RECEIPT_SOURCE_MARK)
3096
+ } catch { machineEntries = [] }
3097
+ let absentState = null
3098
+ for (const line of text.split('\n')) {
3099
+ const am = line.match(SMOKE_ABSENT_LINE_RE)
3100
+ if (am) { absentState = am[1]; break }
3101
+ }
3102
+ return { machineEntries, absentState }
3103
+ }
3104
+
3105
+ /**
3106
+ * 回执槽 smoke 机器段 ensure 式注入(ensureAcceptanceMatrixSection 先例形态,task-02):
3107
+ * 挂 backfill 合并时点(backfillFactsFromMdAndTests——quality-scan step 6 noAI 亲跑落记录
3108
+ * 先于 verify 门禁,读得到实录)。行为:记录态 passed → 段内注入机器段行(单行管道形态,
3109
+ * command 含 |/| 时降多行 YAML 防字段截断)+ 说明注释行;not-configured / not-ran →
3110
+ * 注缺态注释行;无记录 → no-op(不注入,task-03 判 not-ran)。幂等——段内已有同态 CLI 行
3111
+ * 零改动;态迁移(如配置后亲跑:not-configured → passed)时替换 CLI 拥有行为新态。
3112
+ * read-modify-write 基于盘上 md 最新态(不基于调用方快照写回,防覆盖多 agent 并发改动)。
3113
+ * @param {string} mdPath verify-result.md 路径
3114
+ * @param {{ specBase?: string, changeName?: string }} [opts] 缺省自 mdPath 目录上推(judgeIntegrationRan 同款)
3115
+ * @returns {{ changed: boolean, state?: string, reason?: string }}
3116
+ */
3117
+ export function ensureSmokeReceiptSection(mdPath, opts = {}) {
3118
+ const changeDir = dirname(String(mdPath || ''))
3119
+ const changeName = opts.changeName || basename(changeDir)
3120
+ const specBase = opts.specBase || dirname(dirname(changeDir))
3121
+ let text
3122
+ try { text = readFileSync(mdPath, 'utf8') } catch { return { changed: false, reason: 'verify-result.md 不存在或不可读' } }
3123
+ const smoke = readSmokeResultRecord(specBase, changeName)
3124
+ const state = deriveSmokeSectionState(smoke)
3125
+ if (!state) return { changed: false, reason: '无 quality-scan smokeResult 记录(未跑质量扫描)——不注入机器段' }
3126
+ const lines = text.replace(/\r\n/g, '\n').split('\n')
3127
+ const hIdx = lines.findIndex(l => l.startsWith(RECEIPT_SLOT_HEADING))
3128
+ if (hIdx === -1) return { changed: false, reason: `回执槽段(${RECEIPT_SLOT_HEADING})不在场——backfillMissingEvidenceSlots 先补段,下次 ensure 注入` }
3129
+ // 段终点(parseEvidenceSlots sectionOf 同口径:下一 #/## 级标题或文末)
3130
+ let end = lines.length
3131
+ for (let i = hIdx + 1; i < lines.length; i++) {
3132
+ if (/^#{1,2}\s/.test(lines[i])) { end = i; break }
3133
+ }
3134
+ let machineIdx = -1 // 机器段行(单行形态行或多行形态首行)
3135
+ let absentIdx = -1 // 缺态注释行
3136
+ for (let i = hIdx + 1; i < end; i++) {
3137
+ if (machineIdx === -1 && SMOKE_MACHINE_CLAIM_RE.test(lines[i])) machineIdx = i
3138
+ if (absentIdx === -1 && SMOKE_ABSENT_LINE_RE.test(lines[i])) absentIdx = i
3139
+ }
3140
+ // 目标行渲染(passed 双形态:command 含 |/| 时单行管道形态会字段截断 → 降多行 YAML 形态;
3141
+ // exitCode/logPath 非法空值如实渲染——收取侧 fail-closed 不收,postcheck 对比兜底)
3142
+ let targetLines
3143
+ if (state === 'passed') {
3144
+ const command = String(smoke.command || '')
3145
+ const exitVal = typeof smoke.exitCode === 'number' ? smoke.exitCode : ''
3146
+ const noteLine = `<!-- smoke 机器段(CLI ensure 注入,source: ${SMOKE_RECEIPT_SOURCE_MARK})——唯一事实源是 .runtime/verify-quality-scan-${changeName}.json 的 smokeResult,agent 不可改写/删除(verify 一致性抽查会打回);ranAt 实录:${smoke.ranAt || '未知'},durationMs:${typeof smoke.durationMs === 'number' ? smoke.durationMs : '未知'} -->`
3147
+ targetLines = /[||]/.test(command)
3148
+ ? [
3149
+ noteLine,
3150
+ '- claim: commands.smoke CLI 亲跑',
3151
+ ` command: ${command}`,
3152
+ ` exit: ${exitVal}`,
3153
+ ` log: ${smoke.logPath || ''}`,
3154
+ ` source: ${SMOKE_RECEIPT_SOURCE_MARK}`,
3155
+ ]
3156
+ : [
3157
+ noteLine,
3158
+ `- claim: commands.smoke CLI 亲跑 | command: ${command} | exit: ${exitVal} | log: ${smoke.logPath || ''} | source: ${SMOKE_RECEIPT_SOURCE_MARK}`,
3159
+ ]
3160
+ } else {
3161
+ const absentNote = state === 'not-configured'
3162
+ ? 'commands.smoke 未配置——配置 local.yaml 后下次 verify 亲跑并自动注入机器段'
3163
+ : `commands.smoke 已配置但未绿跑(${smoke.reason || '失败/超时'})——修复后重跑 verify 亲测自动更新本段`
3164
+ targetLines = [`<!-- smoke 机器段缺态:${state}(${absentNote})source: ${SMOKE_RECEIPT_SOURCE_MARK} -->`]
3165
+ }
3166
+ // 幂等/态迁移:同态 CLI 行在场 → no-op;异态(含旧缺态行 / 机器段行)→ 原位替换;缺 → 段内末尾注入
3167
+ const inPlaceIdx = machineIdx !== -1 ? machineIdx : absentIdx
3168
+ if (state === 'passed' && machineIdx !== -1) return { changed: false, state, reason: '机器段已在场(幂等跳过)' }
3169
+ if (state !== 'passed' && absentIdx !== -1) {
3170
+ const am = lines[absentIdx].match(SMOKE_ABSENT_LINE_RE)
3171
+ if (am && am[1] === state) return { changed: false, state, reason: `缺态标注已在场(${state},幂等跳过)` }
3172
+ }
3173
+ try {
3174
+ if (inPlaceIdx !== -1) {
3175
+ // 原位替换(CLI 拥有行的态迁移;机器段行 1 行 ↔ 缺态行 1 行,多行 YAML 形态首行替换为整块)
3176
+ let replaceEnd = inPlaceIdx + 1
3177
+ if (SMOKE_MACHINE_CLAIM_RE.test(lines[inPlaceIdx]) && state !== 'passed') {
3178
+ // 旧机器段是多行 YAML 形态时连带其缩进续行一并替换
3179
+ while (replaceEnd < end && /^[ \t]+\w/.test(lines[replaceEnd])) replaceEnd++
3180
+ }
3181
+ lines.splice(inPlaceIdx, replaceEnd - inPlaceIdx, ...targetLines)
3182
+ } else {
3183
+ // 段内末尾注入(最后一个非空行之后)
3184
+ let last = end - 1
3185
+ while (last > hIdx && lines[last].trim() === '') last--
3186
+ lines.splice(last + 1, 0, ...targetLines)
3187
+ }
3188
+ writeFileSync(mdPath, lines.join('\n'))
3189
+ } catch (e) {
3190
+ console.warn(`⚠️ smoke 机器段 ensure 落盘失败(fail-soft,不阻断): ${e && e.message ? e.message : e}`)
3191
+ return { changed: false, state, reason: '落盘失败' }
3192
+ }
3193
+ return { changed: true, state }
3194
+ }
3195
+
1934
3196
  /**
1935
3197
  * 结论草稿槽行预填(P0-1 前置三,noai-ir-roadmap §3):机械事实全绿时把骨架「结论枚举:」
1936
3198
  * 待填槽替换为 PASS 草稿——显式「草稿待确认」内联标注(非默认值语义),agent 复核后可改写;
@@ -2085,6 +3347,10 @@ export function generateVerifyResultSkeleton(result) {
2085
3347
  '## 探针结果(CLI 机械预填) [层:可复跑探针——gate 抽查防篡改]',
2086
3348
  renderVerifyProbesReport(result),
2087
3349
  '',
3350
+ // 接口验证覆盖矩阵段(task-04 / FR-05):紧随探针结果章(probe7 矩阵渲染面之后的独立
3351
+ // ## 章节);result 无 apiFace 键(存量调用方/合成 result)→ 空面渲染「无接口面」注记,零回归。
3352
+ ...renderApiCoverageMatrixLines(result.apiFace),
3353
+ '',
2088
3354
  '## 测试结果 [层:确定性检查——CLI 实测对账]',
2089
3355
  '<!--TODO: 测试命令 + 结果(通过数/失败数;known_failures 豁免逐条注明)-->', /* probe1-noqa */
2090
3356
  '',