Commit 778861b981b33d950723c2b148d0aca657e2710d

Authored by zichun
1 parent 0588d0dc

coding.mjs: harden per-FE behavior gate per multi-agent review (build-failed gua…

…rd, coverage reconciliation, testdb halt)

Post-implementation multi-agent review (6 dims + per-finding adversarial verify) found the control-flow sound and the goal mostly-achieved; this lands the three deterministic, low-risk fixes among the confirmed findings.

- build-failed short-circuit (must-fix): behaviorSubGate now validates the LLM's classification before green-by-skip — requires non-empty rootCausePath AND no interaction/sentinel hard issues riding along; a "dirty" build-failed goes through adjudicate(allowContinue:false) retry/halt instead of silently approving. The skeleton (lazy router + FeStub) makes legit sibling-unimpl build breaks rare, so a build-failed is more likely a real in-FE shared-code regression — the boundary comment §107-108 claimed load-bearing but had zero JS enforcement.

- coverage reconciliation backstop (§3.6): empty-coverage only guarded ==0; now 0<routesReached<routesPlanned with the shortfall unexplained by route-level coverageGaps -> adjudicate(allowContinue:false). Closes the partial-coverage false-green. Counts route-level reasons only; over-counting can only suppress the gate, never false-halt.

- test-DB guard direct-halt now implemented: TESTDB_GUARD_MARK in envError.detail + behaviorTestDbGuardTripped -> immediate HALT on the first result, honoring step2's "no retry, no adjudication" promise (previously prompt-only; a guard trip wrongly entered the generic stack-not-ready retry path, ~5 redundant setup-test-db.mjs runs).

Left as accepted design trade-offs (documented in design §12): self-attested whitelist/route scope, soft-by-source non-data text, disabled-control should-work recovery limited to submit buttons.

Verified: SYNTAX_OK (wrapped check), 87/87 lib tests pass, no time/random builtins. v2 design doc updated (§3/5/6.2/6.3 + new §12).
docs/design/2026-06-02-frontend-behavior-in-review-loop.md
... ... @@ -84,7 +84,7 @@ reviewWithFixLoop(FE):
84 84 **v2 方案**:
85 85  
86 86 1. `BEHAVIOR_GATE_SCHEMA.envError.kind` 枚举**新增 `build-failed`**(确定性失败语义;`route-not-buildable` 不单列,统一用 `build-failed` + detail 区分)。
87   -2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` **既不 retry 也不 halt**——记 `coverageGap`(reason 新增枚举 `build-failed-sibling-unimpl`)+ recordDecisions,**本轮行为门视为「本 FE 行为维度无法判定但非本 FE 缺陷」直接放行 approve**(因为它是「后续 FE 未实现」的预期中途态,不是 FE-N 的 bug;§2 骨架占位让这种情况罕见,一旦发生说明占位未覆盖,留证据供人工)。
  87 +2. **控制流**(在 per-FE 行为门 helper 内):`build-failed` 经**确定性前置校验**后才 green-by-skip 放行(既不 retry 也不 halt);不满足前置 = 「脏」build-failed → 过 `adjudicate(allowContinue:false)` retry/halt,**绝不静默放行**。前置(评审加固,见下「评审加固」):(a) 必须有 `rootCausePath`;(b) 不得同时携带交互/`sentinel` 硬问题。干净放行时记 `coverageGap`(reason `build-failed-sibling-unimpl`)+ recordDecisions(「后续 FE 未实现」的预期中途态,非 FE-N 的 bug;§2 骨架占位让这种情况罕见)。
88 88 3. `behaviorGatePrompt`(per-FE 版)step0/step2 **明确归因指令**:先 `build` / 起 dev server;若失败,先用 `git` / `Grep` 判断报错根因文件路径——
89 89 - 落在**非本 FE 的 frontend/ 路径**(兄弟 FE / 占位未覆盖)→ 判 `envError.kind="build-failed"`(预期中途态)。
90 90 - 落在**本 FE 路径** → 才可能是本 FE 引入的真构建 bug → 归 `interactionFailures[kind="js-error"]` 或带 locator must-fix。
... ... @@ -132,7 +132,7 @@ if (!ALLOW &amp;&amp; !/(^|_)(test|dev|local)$|(^|_)test_|^test_/.test(DB_SCHEMA) &amp;&amp; !/t
132 132 (具体正则以实现为准,语义=库名须含 `test`/`_test`/`_dev`/`_local` 之一,否则 fail-closed。)
133 133  
134 134 - 这样不论被行为门调用多少次都安全。
135   -- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**保持不重试不仲裁直接 throw** 的硬边界语义(与现 v1 一致,见 behaviorGatePrompt step2 第 1 条)。
  135 +- coding.mjs 行为门控制流里,对「测试库护栏触发的红」**不重试不仲裁直接 throw** 的硬边界语义现已落地为确定性机制(评审加固):门子代理在 `envError.detail` 以固定标记 `TESTDB_GUARD_MARK`(`[TESTDB-GUARD]`)开头,`runBehaviorGateOnce` 拿到首个结果即 `behaviorTestDbGuardTripped` 命中 → 立即 throw HALT,绝不进入 attempt 重试 / adjudicate(此前仅 step2 prompt 承诺、JS 无兑现,护栏红会误入 stack-not-ready 通用重试路径空转约 5 次)。
136 136 - 这是 skeleton-gen 模板的一次性改动,**不属于 coding.mjs 改造**,但列为本设计前置(否则反复起栈的安全暴露面不可接受)。
137 137  
138 138 ---
... ... @@ -154,7 +154,8 @@ approve 出口(现 1386 `if (r.verdict===&#39;approve&#39;)` 分支)**改为合取**
154 154 ```
155 155 reviewer.verdict==='approve'
156 156 ∧ behaviorSubGate(FE) 返回 green
157   - 其中 green ≡ behaviorHard.length===0 ∧ envError∈{none, build-failed} ∧ 本FE覆盖非空(或 build-failed 短路)
  157 + 其中 green ≡ behaviorHard.length===0 ∧ envError∈{none, build-failed(经前置校验)} ∧ 本FE覆盖非空
  158 + ∧ 无未解释漏达路由(routesReached + 路由级 coverageGap ≥ routesPlanned) (或干净 build-failed 短路)
158 159 ```
159 160  
160 161 只有合取成立才 `flipDocs08Checkbox` + `return {approved:true}`。这保证(采纳「删阶段门」维度 blocker 的钉死):
... ... @@ -168,13 +169,18 @@ reviewer.verdict===&#39;approve&#39;
168 169 async function behaviorSubGate(id, specPath, feScope):
169 170 // feScope = {routes:[...], controlWhitelist:[...]}(来自 §4 spec 结构化小节)
170 171 for behaviorRound in 1..BEHAVIOR_FE_MAX(=3):
171   - bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 envError attempt 重试
172   - // 1) build-failed 短路(依赖 B):兄弟未实现 → 记 coverageGap + decisions,子门视为 green-by-skip,return passed
173   - if (bg.envError.kind === 'build-failed' && 根因在非本FE路径) { recordDecisions; return {green:true, skipped:true} }
  172 + bg = await runBehaviorGateOnce(id, behaviorRound, feScope) // 见 §7,内含 testdb-guard 直接 halt + envError attempt 重试
  173 + // 1) build-failed:经前置校验后短路(依赖 B)。脏(无 rootCausePath / 携带交互|sentinel 硬问题) → adjudicate(allowContinue:false),绝不静默放行
  174 + if (bg.envError.kind === 'build-failed') {
  175 + if (dirty(bg)) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 }
  176 + else { recordDecisions; return {green:true, skipped:true} } // 干净:兄弟未实现,green-by-skip
  177 + }
174 178 // 2) envError(其它) / 空覆盖:runBehaviorGateOnce 内部已 attempt 重试;到这里仍 blocked → adjudicate(allowContinue:false) retry/halt
175 179 if (envBlocked(bg)) { adjudicate; 仍 blocked → throw HALT }
176 180 // 3) 软文字:for-of 走 adjudicate;continue→recordDecisions + 加入跨轮 softPassed;sentinel→并入 behaviorHard;retry/halt 同现
177 181 processTextIssues(bg, softPassed) // softPassed 提升到 reviewWithFixLoop 顶层作用域,跨 behaviorRound 持久
  182 + // 3.6) 覆盖率对账(确定性兜底):未被路由级 coverageGap 解释的漏达路由(routesReached<routesPlanned) → adjudicate(allowContinue:false)
  183 + if (planned>0 && planned-reached-routeGapCount > 0) { v=adjudicate(allowContinue:false); v!=='retry' → throw HALT; else 下一轮重跑 }
178 184 // 4) behaviorHard = interactionFailures + sentinel textIssues
179 185 if (behaviorHard.length === 0) return {green:true}
180 186 // 5) 分流
... ... @@ -286,3 +292,15 @@ async function behaviorSubGate(id, specPath, feScope):
286 292 7. README / coding-start SKILL 文案。
287 293  
288 294 每步后端分支必须逐字不变(diff 校验);运行时红线(time/random builtin / 顶层 return / 注入全局)每步复核。
  295 +
  296 +---
  297 +
  298 +## 12. 评审加固(实现后多代理评审产出,已落地)
  299 +
  300 +实现后用多代理评审(6 维 + 逐发现对抗复核)核验本设计的逻辑与目标达成。控制流(approve 合取不变量、fix 轮边界、无 stale-green、残留清除)与运行时红线(确定性 builtin / 顶层 return / 16 schema 合法)全部通过。目标维度(「每控件可用 / 每文字正确」)判为 mostly-achieves,确认了若干 escapability 缺口,其中确定性、低风险的三项已加固:
  301 +
  302 +1. **`build-failed` 短路加确定性前置(头号 must-fix)**:`behaviorSubGate` 在 green-by-skip 前校验 (a) `rootCausePath` 非空、(b) 无交互/`sentinel` 硬问题搭车;任一不满足 → `adjudicate(allowContinue:false)` retry/halt,绝不凭未校验的 LLM 归因静默放行。骨架(lazy router + FeStub)令合法的兄弟未实现 build-failed 极罕见,故一个 build-failed 更可能是本 FE 真共享代码回归——这是此前 comment §107-108 声称 load-bearing 却无 JS 兜底的边界。
  303 +2. **覆盖率对账兜底(§3.6)**:空覆盖此前只兜 `==0`;新增 `routesReached < routesPlanned` 且缺口未被「路由级 coverageGap」解释时 → `adjudicate(allowContinue:false)`,堵住「部分覆盖假绿」(只数路由级 reason,过计只抑制本门、绝不误 halt)。
  304 +3. **测试库护栏直接 halt 落地**:用 `TESTDB_GUARD_MARK` 标记 + `behaviorTestDbGuardTripped` 兑现 step2「不重试不仲裁直接 halt」承诺(详见 §5)。
  305 +
  306 +未改(确认为可接受的设计取舍,留作后续):白名单/路由作用域自证(非从 live router/DOM 独立枚举)、非数据文字按 source 软分流、disabled 控件仅提交类有 should-work 复核——均为有意取舍,记于此供后续权衡。
... ...
workflows/coding.mjs
... ... @@ -614,7 +614,7 @@ function behaviorGatePrompt(id, specPath, behaviorRound, attempt) {
614 614 '- **未建兄弟路由既不计入分母也不计 coverageGap**(属预期中途态,按 step0 归 build-failed 短路)。',
615 615 '',
616 616 '## step2 安全护栏 + 起栈四段严格时序(schema 由 Flyway 在后端启动时才建)',
617   - `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + 在 detail 写明「测试库护栏触发」(上层对此**不重试不仲裁直接 halt**,留人工确认)。`,
  617 + `1) **测试库安全护栏**:测试库命名护栏现已下沉到 \`${ROOT}/scripts/setup-test-db.mjs\` 模板自身(确定性 JS 边界,库名须含 test/_test/_dev/_local,否则 fail-closed,\`ALLOW_NONTEST_DROP=1\` 显式放行)。runner 可复述但模板是唯一防线;若模板因测试库护栏非零退出 → 返回 \`status:red\` + \`envError.kind="stack-not-ready"\` + \`envError.detail\` **以固定标记 \`${TESTDB_GUARD_MARK}\` 开头**并写明「测试库护栏触发」+ 库名。上层据此标记**不重试不仲裁直接 halt**(留人工确认)——务必只在确为护栏触发时打此标记,其它起栈失败照常用普通 detail。`,
618 618 `2) \`node ${ROOT}/scripts/setup-test-db.mjs\`(DROP+CREATE 空库)。DROP 前按 \`${tmpDir}/*.pid\` / 既知端口优雅回收残留进程。`,
619 619 '3) **起后端**:spawn 到后台 + 轮询 `/actuator/health` 或登录端点 200(Flyway 在此 apply 建 schema);端口取 config-vars,先探测占用,占用则回收残留或退到动态空闲端口 + 把 baseURL 注入下游。',
620 620 '4) **此时才跑种子**:按 `docs/03-数据库设计文档.md` 派生 **FK 有序 INSERT** 种子(先父后子)。失败 → `envError.kind="seed-error"` + 结构化根因(缺列 / 撞唯一键 / enum 越界 / FK 序错 / 类型截断),**不**混进交互 RED。',
... ... @@ -757,6 +757,8 @@ const ADJUDICATE_MAX = 3 // 单个 site 的仲裁轮上限
757 757 // - BEHAVIOR_ATTEMPT_MAX = 单个 behaviorRound 内的环境 race 重起上限(沿用 testGate attempt 1→2 思路)。
758 758 const BEHAVIOR_FE_MAX = 3
759 759 const BEHAVIOR_ATTEMPT_MAX = 2
  760 +// 测试库护栏触发的确定性标记:门子代理在 envError.detail 以此开头,JS 据此「不重试不仲裁直接 halt」(兑现 step2 第 1 条承诺)。
  761 +const TESTDB_GUARD_MARK = '[TESTDB-GUARD]'
760 762 const adjGuidance = (g) => g ? `\n\n## 仲裁返回的纠正指令(本次重跑必须遵守)\n${g}` : ''
761 763  
762 764 // 全流程自主决策日志:stage 缺值时不停而是挑默认/解读,登记在此,随结果回传供人工事后审阅。
... ... @@ -1646,6 +1648,11 @@ function behaviorEnvBlocked(r) {
1646 1648 return { ev, emptyCov, blocked: !!ev || emptyCov }
1647 1649 }
1648 1650 function behaviorIfails(r) { return Array.isArray(r.interactionFailures) ? r.interactionFailures : [] }
  1651 +// 测试库护栏触发判定:门子代理按 step2 第 1 条在 envError.detail 打 TESTDB_GUARD_MARK;命中即确定性 halt(不重试不仲裁)。
  1652 +function behaviorTestDbGuardTripped(r) {
  1653 + const d = (r.envError && r.envError.detail) || ''
  1654 + return typeof d === 'string' && d.includes(TESTDB_GUARD_MARK)
  1655 +}
1649 1656  
1650 1657 // runBehaviorGateOnce:跑一次本 FE 行为验收(含内部 envError attempt 重试 + 空覆盖兜底)。
1651 1658 // 返回最终 bg(BEHAVIOR_GATE_SCHEMA);不在内部收敛交互/文字(交给外层 behaviorSubGate 推进)。
... ... @@ -1657,6 +1664,10 @@ async function runBehaviorGateOnce(id, specPath, grp, behaviorRound) {
1657 1664 {label: lbl(attempt), phase: grp, schema: BEHAVIOR_GATE_SCHEMA})
1658 1665 recordDecisions(`behavior:${id}`, bg.decisions)
1659 1666  
  1667 + // 测试库护栏触发 → 不重试不仲裁直接 halt(兑现 step2 第 1 条承诺;首个结果即拦截,绝不进重试/仲裁烧预算)。
  1668 + if (behaviorTestDbGuardTripped(bg))
  1669 + throw new Error(`HALT behavior-testdb-guard ${id}: 测试库护栏触发(库名非测试库),不重试不仲裁直接 halt 待人工确认 — ${(bg.envError || {}).detail || ''}`)
  1670 +
1660 1671 // build-failed 短路:根因落非本 FE 路径(兄弟未实现)→ 直接返回(外层据此放行 approve),不重试不仲裁。
1661 1672 const isBuildFailedShortCircuit = (r) => r.envError && r.envError.kind === 'build-failed'
1662 1673 if (isBuildFailedShortCircuit(bg)) return bg
... ... @@ -1696,13 +1707,34 @@ async function behaviorSubGate(id, specPath, grp, softPassed) {
1696 1707 for (let behaviorRound = 1; behaviorRound <= BEHAVIOR_FE_MAX; behaviorRound++) {
1697 1708 const bg = await runBehaviorGateOnce(id, specPath, grp, behaviorRound)
1698 1709  
1699   - // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → 记 coverageGap + decisions,子门 green-by-skip 放行。
  1710 + // 1) build-failed 短路(依赖 B):兄弟未实现 / 占位未覆盖 → green-by-skip 放行。但骨架(lazy router + FeStub)
  1711 + // 令「合法的兄弟未实现 build-failed」极罕见,故一个 build-failed 更可能是本 FE 引入的真共享代码回归;
  1712 + // 绝不凭未校验的 LLM 归因静默放行——先过确定性前置校验(comment §107-108 声称 load-bearing 的边界,此前无 JS 兜底):
  1713 + // a) 必须有 rootCausePath(否则无从判定根因落点);
  1714 + // b) 不得同时携带交互硬问题(interactionFailures / source=sentinel 文字)——那是真缺陷搭车。
  1715 + // 任一不满足 = 「脏」build-failed → 不短路,过 adjudicate(allowContinue:false) retry/halt,绝不 green-by-skip。
1700 1716 if (bg.envError && bg.envError.kind === 'build-failed') {
  1717 + const rootCausePath = (bg.envError.rootCausePath || '').trim()
  1718 + const hardRiders = behaviorIfails(bg).length
  1719 + + (Array.isArray(bg.textIssues) ? bg.textIssues : []).filter(t => t && t.source === 'sentinel').length
  1720 + const dirty = !rootCausePath
  1721 + ? 'build-failed 未给 rootCausePath(无法判定根因是否落在本 FE 之外)'
  1722 + : hardRiders
  1723 + ? `build-failed 同时携带 ${hardRiders} 项交互/sentinel 硬问题(疑似本 FE 真构建 bug 搭车)`
  1724 + : null
  1725 + if (dirty) {
  1726 + const verdict = await adjudicate(`behavior-buildfailed-dirty:${id}`,
  1727 + { problem:`build-failed 归因不可信,绝不短路放行:${dirty}(rootCausePath=${rootCausePath || '∅'})`,
  1728 + envError: bg.envError, allowContinue:false }, grp, behaviorRound)
  1729 + if (verdict.action !== 'retry') throw new Error(`HALT behavior-buildfailed ${id}: ${verdict.rationale || dirty}`)
  1730 + continue // retry → 下一 behaviorRound 重跑整门
  1731 + }
  1732 + // 干净的 build-failed(有 rootCausePath 且无硬问题搭车)→ green-by-skip 放行,记低置信证据。
1701 1733 recordDecisions(`behavior-build-failed:${id}`, [{
1702   - question:`本 FE ${id} 行为验收遇 build-failed(根因 ${bg.envError.rootCausePath || '?'})`,
  1734 + question:`本 FE ${id} 行为验收遇 build-failed(根因 ${rootCausePath})`,
1703 1735 choice:'green-by-skip(兄弟 FE 未实现属预期中途态,本 FE 非缺陷,放行 approve)',
1704 1736 rationale: bg.envError.detail || '', confidence:'low' }])
1705   - log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${bg.envError.rootCausePath || '?'}),记证据不阻断`)
  1737 + log(`behavior ${id}: build-failed 短路放行(根因非本 FE:${rootCausePath}),记证据不阻断`)
1706 1738 return
1707 1739 }
1708 1740  
... ... @@ -1746,13 +1778,29 @@ async function behaviorSubGate(id, specPath, grp, softPassed) {
1746 1778 continue // retry → 重跑本 FE 行为验收(下一 behaviorRound)
1747 1779 }
1748 1780  
  1781 + // 3.6) 覆盖率对账(确定性兜底):空覆盖只兜 ==0;这里兜 0<routesReached<routesPlanned 的「部分覆盖假绿」。
  1782 + // 每条 planned-but-unreached 路由必须由「路由级 coverageGap」解释;未被解释的漏达路由 = 静默漏验,绝不判 green。
  1783 + // 只数路由级 reason(控件级 deep-control-not-driven / locator-not-resolvable 不抵漏达路由);过计只会抑制本门、绝不误 halt。
  1784 + const planned = Number(bg.routesPlanned) || 0
  1785 + const reached = Number(bg.routesReached) || 0
  1786 + const ROUTE_GAP = new Set(['unreachable-auth', 'unreachable-no-route', 'dynamic-route-no-seed', 'build-failed-sibling-unimpl'])
  1787 + const routeGapCount = (Array.isArray(bg.coverageGaps) ? bg.coverageGaps : []).filter(cg => cg && ROUTE_GAP.has(cg.reason)).length
  1788 + const unaccounted = planned - reached - routeGapCount
  1789 + if (planned > 0 && unaccounted > 0) {
  1790 + const verdict = await adjudicate(`behavior-undercoverage:${id}`,
  1791 + { problem:`本 FE 路由覆盖不足:routesPlanned=${planned} routesReached=${reached},仅 ${routeGapCount} 条有路由级 coverageGap 解释,尚有 ${unaccounted} 条漏达路由无证据(绝不带静默漏达判 green)`,
  1792 + coverageGaps: bg.coverageGaps || [], allowContinue: false }, grp, behaviorRound)
  1793 + if (verdict.action !== 'retry') throw new Error(`HALT behavior-undercoverage ${id}: ${verdict.rationale || `${unaccounted} 条漏达路由无证据`}`)
  1794 + continue // retry → 下一 behaviorRound 重跑整门
  1795 + }
  1796 +
1749 1797 // 4) behaviorHard = interactionFailures(含 binding-garbage)+ source=='sentinel' textIssues。
1750 1798 const sentinelHard = (Array.isArray(bg.textIssues) ? bg.textIssues : [])
1751 1799 .filter(t => t && t.source === 'sentinel')
1752 1800 .map(t => ({ page:t.page, control:t.region, kind:'binding-garbage', detail:`sentinel 不符 期望=${t.expected} 实际=${t.actual}`, locator:t.locator }))
1753 1801 const behaviorHard = [...behaviorIfails(bg), ...sentinelHard]
1754 1802  
1755   - // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)→ 子门 green 放行。
  1803 + // 5) green 判定:behaviorHard 为空 ∧ 无 B 类未覆盖 ∧ 覆盖非空(已兜底)∧ 无未解释漏达路由(§3.6 已兜底)→ 子门 green 放行。
1756 1804 if (behaviorHard.length === 0) {
1757 1805 log(`behavior ${id} green(behaviorRound=${behaviorRound} routesPlanned=${bg.routesPlanned} routesReached=${bg.routesReached} controls=${bg.controlsEnumerated} authState=${bg.authState || '?'})`)
1758 1806 return
... ...