sillyspec 3.28.12 → 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.
- package/.claude/skills/sillyspec-execute/SKILL.md +222 -223
- package/.claude/skills/sillyspec-plan/SKILL.md +156 -156
- package/.claude/skills/sillyspec-quick/SKILL.md +8 -8
- package/.claude/skills/sillyspec-verify/SKILL.md +87 -91
- package/package.json +1 -1
- package/src/change-risk-profile.js +3 -3
- package/src/decision-distill.js +32 -25
- package/src/doctor-diagnostics.js +29 -0
- package/src/fr-index.js +359 -0
- package/src/knowledge-stats.js +99 -2
- package/src/run/archive-distill.js +29 -0
- package/src/run/prompt.js +32 -1
- package/src/stage-contract.js +70 -17
- package/src/stages/brainstorm.js +6 -1
- package/src/verify-probes.js +847 -20
package/src/verify-probes.js
CHANGED
|
@@ -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)、契约必填
|
|
@@ -377,12 +1140,20 @@ export function classifyConsumerHints(paths) {
|
|
|
377
1140
|
* 探针 8 主体:design 清单三面文件(Java/SQL/前端)→ 双根(主仓 ∪ worktree)∪ 跨仓注册仓根
|
|
378
1141
|
* 读取 → 归一化比对。advisory:所有输出是「候选」不是结论(UI 本地态键/服务端填充列会自然
|
|
379
1142
|
* 出现在差异里,agent 逐条复核——口径注记随渲染输出)。
|
|
380
|
-
* @param {{ specBase: string, cwd: string, wtRoot?: string|null, changeName: string
|
|
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 行(详见函数尾接线块)。
|
|
381
1149
|
* @returns {{applicable, backendFieldCount, feKeyCount, notNullCount, javaFileCount, sqlFileCount, feFileCount,
|
|
382
1150
|
* mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
|
|
383
|
-
* 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}}}
|
|
384
1155
|
*/
|
|
385
|
-
export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName }) {
|
|
1156
|
+
export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeName, diffFiles = null }) {
|
|
386
1157
|
const out = {
|
|
387
1158
|
applicable: false, backendFieldCount: 0, feKeyCount: 0, notNullCount: 0,
|
|
388
1159
|
javaFileCount: 0, sqlFileCount: 0, feFileCount: 0,
|
|
@@ -548,6 +1319,57 @@ export function runProbe8PayloadParity({ specBase, cwd, wtRoot = null, changeNam
|
|
|
548
1319
|
out.backendFieldCount = backendFields.size
|
|
549
1320
|
out.feKeyCount = feKeys.size
|
|
550
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
|
+
}
|
|
551
1373
|
return out
|
|
552
1374
|
}
|
|
553
1375
|
|
|
@@ -1469,7 +2291,12 @@ export function renderVerifyProbesReport(result) {
|
|
|
1469
2291
|
* @param {{applicable: boolean, backendFieldCount?: number, feKeyCount?: number, notNullCount?: number,
|
|
1470
2292
|
* javaFileCount?: number, sqlFileCount?: number, feFileCount?: number,
|
|
1471
2293
|
* mispairs: Array<{fe,be}>, feOnly: string[], missingNotNull: Array<{col}>, notes: string[],
|
|
1472
|
-
* contractCount?: number, contractOrphans?: Array<{fe, hint?}>, missingRequired?: Array<{field, contract}
|
|
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 块静默跳过,零回归。
|
|
1473
2300
|
* @returns {string[]} 行数组(含段标题)
|
|
1474
2301
|
*/
|
|
1475
2302
|
function renderProbe8Lines(p8) {
|
|
@@ -1503,6 +2330,10 @@ function renderProbe8Lines(p8) {
|
|
|
1503
2330
|
L.push('- ✅ 载荷字段面零疑似差异(归一化覆盖 + NOT NULL 全见)')
|
|
1504
2331
|
}
|
|
1505
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))
|
|
1506
2337
|
return L
|
|
1507
2338
|
}
|
|
1508
2339
|
|
|
@@ -1748,7 +2579,7 @@ const HANDOVER_DEFAULT_SEVERITY = (type) =>
|
|
|
1748
2579
|
// 降级理由文法(X-02):(降级:<理由>,依据 <file:line 或 D-xxx>)——全角括号/冒号/逗号,
|
|
1749
2580
|
// 依据锚点 D-xxx 或 file:line。JS 正则不转义全角括号(知识库坑)。blocking→advisory 降级
|
|
1750
2581
|
// 未命中本文法 → 仍按 blocking(fail-closed,防「全标 advisory」钻空——D-005@v2)。
|
|
1751
|
-
const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d
|
|
2582
|
+
const HANDOVER_DOWNGRADE_REASON_RE = /(降级:.+?,依据\s*(?:D-\d+(?:@\d+)?|[^\s,):]+:\d+))/
|
|
1752
2583
|
// severity 解析:显式 blocking 优先(混写保守);显式 advisory 对 blocking 缺省类型构成降级,
|
|
1753
2584
|
// 理由文法在第 4 列或条件列任一命中即认(骨架第 4 列定型前 producer 宽收两侧;抽查面归
|
|
1754
2585
|
// checkProbeConsistency 消费侧后续任务);两枚举都不含 → 缺省映射。
|
|
@@ -1778,6 +2609,8 @@ export function parseHandoverRows(md) {
|
|
|
1778
2609
|
const condition = row[3].trim()
|
|
1779
2610
|
if (type === '类型' || /^-{2,}$/.test(type.replace(/\|/g, ''))) continue // 表头/分隔行
|
|
1780
2611
|
if (type.startsWith('<') || item.startsWith('<')) continue // 骨架占位行
|
|
2612
|
+
if (type === '无' || item === '无') continue // 裸「无」行=骨架指引的空表占位(结论=PASS/FAIL 写「无」)——
|
|
2613
|
+
// 真「无可移交」的合法表达是矩阵干净+零有效行,不是一行无;精确相等防误杀「无明确去向」类真条目
|
|
1781
2614
|
if (!type || !item) continue
|
|
1782
2615
|
const normalizedType = type.toLowerCase().replace(/[\s_]+/g, '-')
|
|
1783
2616
|
items.push({
|
|
@@ -1839,17 +2672,10 @@ export function parseDbScriptDeclarations(md) {
|
|
|
1839
2672
|
// 正则下全判 build 会误拦金路径,且脚本形态词表不可枚举(RECEIPT_CROSS_LAYER_RE 正则族
|
|
1840
2673
|
// 零词变动)。对齐目标:change-risk-profile.js classifyReceiptSourceTag(sourceMark 参数与
|
|
1841
2674
|
// CLI_SMOKE_SOURCE_MARK 常量)——**任一侧增改标记族必须双侧同步**(G-3 铁律)。
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
|
|
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
|
|
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 根因)。
|
|
1851
2678
|
/** CLI 机器段来源标记(与 change-risk-profile.js CLI_SMOKE_SOURCE_MARK / task-01 记录 source 同值,双侧同步) */
|
|
1852
|
-
const SMOKE_RECEIPT_SOURCE_MARK = 'cli-noai-smoke'
|
|
1853
2679
|
function classifyReceiptCommandSource(command, sourceMark) {
|
|
1854
2680
|
if (sourceMark === SMOKE_RECEIPT_SOURCE_MARK) return 'cross-layer'
|
|
1855
2681
|
const cmd = String(command || '')
|
|
@@ -1977,14 +2803,15 @@ export function backfillFactsFromMdAndTests(factsPath, { verifyMd, testCheckResu
|
|
|
1977
2803
|
if (slots.hasReceiptSlot && slots.runtimeEvidence.length > 0) {
|
|
1978
2804
|
facts.runtimeEvidence = slots.runtimeEvidence
|
|
1979
2805
|
}
|
|
1980
|
-
// 移交项结构化回填(handover-only-in-prose
|
|
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 形态——字段在场零行=净,本行只是对齐既有契约);
|
|
1981
2810
|
// PASS WITH NOTES 零有效行 → advisory 警告(不阻断——存量 PASS WITH NOTES 无此章节是常态,
|
|
1982
2811
|
// 渐进采纳)。EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1,清单化才有
|
|
1983
2812
|
// 「谁兜、怎么复跑」的可追溯面。
|
|
1984
2813
|
const handoverItems = parseHandoverRows(verifyMdText || '')
|
|
1985
|
-
|
|
1986
|
-
facts.handover = { count: handoverItems.length, items: handoverItems }
|
|
1987
|
-
}
|
|
2814
|
+
facts.handover = { count: handoverItems.length, items: handoverItems }
|
|
1988
2815
|
if (conclusionSlot === 'PASS WITH NOTES' && handoverItems.length === 0) {
|
|
1989
2816
|
console.warn('⚠️ 结论=PASS WITH NOTES 但「移交项(结构化)」章节零有效行——正文叙述的移交项(环境阻断复跑条件/人工验收步骤/待执行脚本)请结构化进 ## 移交项(结构化) 表格(类型枚举 env-blocked/manual-acceptance/db-script/other),避免移交项只活在 prose 里没人兜(2026-09-15 EHS 实证:被环境阻断 deferred 的集成测试里藏着 5 个 P1)。')
|
|
1990
2817
|
}
|