# INFRA-F129 ① 治本项 —— `expected` 缺失时发出「验不了」而不是不发行

> 2026-08-21 · 判据设计稿。**本文件只定判据 + 记被否掉的候选**；
> 实现落地后，语义的**唯一真源 = `tests/visual-verify/lib/drift-compare-core.ts` 的
> `UNVERIFIABLE_STATUS` 头注释块**（⛔ 别在这里维护第二份会漂的副本）。
>
> 前置事实真源 = [backlog INFRA-F129](../../internal/backlog.md) ① 那条 + 量尺
> `scripts/f129-rootradius-silent-gap-probe.mjs` 头注释。⛔ 本文件不重复那边的数。

---

## 1. 病灶（一句话）

`pushNumber` 在 `expected === null` 时 **early-return** ⇒ 那条 check **连行都不发出**。
它是「**看不见**」而不是「已通过」：不会红 · 不进豁免表 · 在 report 里一行都没有。

## 2. 先量清作用面（⛔ 判据之前，不许凭 entry 的转述）

entry 只登记了 `rootRadius`。**实测 936 条 manifest 上，`expected` 可能为 null 而走静默路径的字段有 5 个**
（`pushNumber` / `pushSizedNumber` 各自的 `if (expected === null) return`）：

| 字段 | 类型允许 null | 今日实测 null 条数 |
|---|---|---|
| `rootRadius` | ✅ | **284**（17 个组件） |
| `rootWidth` | ✅（经 `pushSizedNumber`） | 0 |
| `rootHeight` | ✅（同上） | 0 |
| `textInset.left` | ✅ | 0（64 条 `textInset` entry 全部两侧有值） |
| `textInset.right` | ✅ | 0（同上） |

⇒ **今天只有 `rootRadius` 真的在静默**，另外四个是**潜伏路径**（同一个 early-return，数据一变就静默）。
⇒ 判据必须落在**机制**上（那个 early-return），⛔ 不许只给 `rootRadius` 加特例（反模式 #2）。

`rootPadding.*` / `rootGap` / `rootOpacity` / `rootBorderWidth` 的类型是 `number`（非 null），
实测 0 条 null ⇒ 不在本条作用面，但它们**共用同一个 `pushNumber`**，机制修好后自动覆盖。

## 3. 判据：缺值时该发出什么状态

**问题**：什么状态才「既不算 pass 也不算 fail、又不伪装成通过」。

### 3a. 三个候选，两个当场被活源否掉

| 候选 | 为什么不行（**读代码得出，不是推测**） |
|---|---|
| **`pass: false`（当 fail）** | ① `manifest-verifier.spec.ts:177` 的 entry status 表达式 `checks.some(c => !c.pass) ? 'FAIL'` ⇒ 284 条 entry 当场变 FAIL ② `classifyFailedCheck` 第一行 `if (check.pass) return undefined` ⇒ 它们会被分类成 `A_TRUE_DRIFT_CANDIDATE`，而两条闸的 `BASELINE_A = 0` ⇒ 两条链同时红 ③ **语义也是错的**：`FIGMA_AS_SOURCE_OF_TRUTH.md` 的「默认结论是 code bug」前提是**观察到了差异**；这里根本没有可比的东西，把「Figma 没说」判成「code 错了」是编结论 |
| **复用 `pass-by-mode-skip`** | ① 它 `pass: true` ⇒ 经 entry status 落进 `PASS_BY_MODE_SKIP`，而 `passRate = (pass + passByModeSkip) / total` ⇒ **直接进 pass 率的分子** = 逐字的「伪装成通过」 ② 它的既有语义是「**Figma 说了**，但这个模式下比不上」（HUG / FILL / 子元素 / border-width 0）—— 与「Figma **根本没说**」是两件不同的事实，合并就再也分不开。⚠️ 现存 `textFillHex === null` 与 `rootBorderHex`（width 0）两处**正是**用 skip 表达「Figma 没说」的存量样本 ⇒ 本轮之后它们属于同一类待收口项（见 §6 残余） |
| **✅ 第四种状态 `unverifiable`** | 见下 |

### 3b. 采纳：`status: 'unverifiable'`

```ts
{ field, actual: <量到的真实值>, expected: null, pass: true, status: 'unverifiable', reason: … }
```

三个字段各自的**精确含义**（⛔ 别读宽）：

- **`status: 'unverifiable'`** = 判据本体。「Figma 侧没有可比的值」这一个事实，独立成档。
- **`pass: true`** = **只表示「不判它红」**，⛔ **不表示「验过了」**。
  「验过了」在本仓的唯一口径是 `status === 'pass'`。
  为什么必须 `true`：上表第一行那三条（entry status / A 分类 / 两闸 baseline）。
- **`actual` 照实记** = 本轮最大的意外收获，见 §5。

**pass 率处理 = 不动分子分母，另立一栏。**
`pass` / `passByModeSkip` / `fail` / `passRate` 全部是 **entry 级**且**逐字不变**；
`unverifiable` 是 **check 级第三个桶**，在 summary 里单列 `unverifiableChecks: { total, byField, byComponent }`。

理由（这是本判据最容易被质疑的一处，逐条写清）：

1. **entry 级二值化会丢信息**：一条 entry 16 个字段里 1 个验不了 vs 10 个验不了，
   在「entry 算不算 PASS」这个粒度上不可区分。check 级计数才是**信息量更大**的那个测量。
2. **`passRate` 本来就不承担「全字段都验过」这个断言** —— 承担它的是 `measuredEntries`（S4）与
   本轮新增的 `unverifiableChecks`。把「未验字段数」压进一个 entry 级比率，等于让一个数字回答两个问题。
3. **头条读数不会被误读**，因为 summary 表**同排**多印一栏 `Unverifiable checks`，
   且每条 entry 的 markdown 行加 `**Unverified:**` 子行（与既有 `**Skipped:**` 同形态）。

**被考虑过但没选的更狠版本**：新增 entry 级状态 `PASS_WITH_UNVERIFIED` 并把它踢出 `passRate` 分子。
它会让 Vue 链 `passRate` 从 89.6% 掉到约 60%（284/936 条 entry 至少有一个未验字段）。
⛔ 没选的理由是上面 1 与 2，**不是**「怕数字变丑」。⚠️ 这是**可逆**的一步：若 owner 要更狠的头条读数，
改的只有 `deriveEntryStatus()` 一处（本轮顺带把它从两份 spec 里抽成单一定义，正是为此）。

## 4. 作用面 + 每一处怎么处置（⛔ 逐处读过代码，不是清单式列举）

| 作用面 | 影响 | 处置 |
|---|---|---|
| `pushNumber` / `pushSizedNumber` | 病灶本体，两处 `if (expected === null) return` | `pushSizedNumber` 的 null 分支**委派**给 `pushNumber`（判据只写一遍） |
| entry status 表达式 | **在两份 spec 里各写了一遍**（`manifest-verifier.spec.ts:177` + `react-drift-full.spec.ts:223`）⇒ 反模式 #5 | 抽 `deriveEntryStatus(checks)` 进 core，两侧改调用。⚠️ 语义**零变化**：`unverifiable` 因 `pass: true` 且 `status !== 'pass-by-mode-skip'` 而落 `PASS`/`PASS_BY_MODE_SKIP`，与今天完全一致 |
| `summarize()` | 需要第三个桶 | 加 `unverifiableChecks`；⛔ 不动 pass/fail/passRate/classifications/excuse 任何一个数 |
| `CHECK_FIELDS` | 它是 **field 名**闭集，本轮不新增任何 field 名 | **零改动**（曾担心要动，实测不用） |
| 豁免表 matcher | `isExcused` 只从 `classifyFailedCheck` 调，而它 `if (check.pass) return undefined`；`summarize` 的三处匹配都要求 `check.status === 'fail'` | **结构上碰不到** —— `unverifiable` 永远进不了豁免表。这是好事：⛔ 别让「验不了」变成一种可以写行豁免掉的东西 |
| `audit:render-drift-gate`（两条链） | 读 `classifications.A` / `total` / `navigationFailures` / `measuredEntries` / `excuse.*` / `nodeCoverage.mismatches` / `inputsFingerprint` —— **没有一个会变** | 加 **S10** = `summary.unverifiableChecks` 形状 fail-closed（同 S5 对 `summary.excuse` 的形态）。⛔ 刻意**不设 baseline 数字**：那会与 `audit:render-silent-checks` 的棘轮撞成「同一件事红两次」（该闸头注释逐字钉过这条） |
| `audit:render-silent-checks`（= probe `--gate`） | 🔴 **它的 S1 逐字断言 `expected===null ⇔ report 里该行缺失`** ⇒ 本改动会让它的 284 条全部变成「反例」、两处挂载（L4 pre-commit + L5 pr-checks）当场红 | S1 的不变式**升级**为：null ⇒ 行在且 `status === 'unverifiable'`；non-null ⇒ 行在且 `status ∈ {pass, fail, pass-by-mode-skip}`。**比旧版严**（旧版只看行在不在，看不见状态）。⚠️ 顺带修一个潜伏脆弱点：`missingTarget` 的 entry 在 `buildChecks` 里提前 return、压根没有 `rootRadius` 行 ⇒ 旧 S1 会把它误判成反例（今日实测 `renderTarget` 行 = 0 条，所以没炸过）。新版单列这一档、不折进任何一档 |
| `render-drift-summary.mjs`（`drift:summary`，零挂载的人工报表） | 首行 `if (check.pass) return null` ⇒ 跳过 | 零改动 |

## 5. 意外收获：本改动把 probe 声明的 L1 缺口变成**可离线派生**

probe 头注释的诚实边界 ① 逐字写着：

> report 只存 `checks`，不存 actual 快照 ⇒ 对这 284 个 entry，仓库里**根本没有**任何一个记录过它们的 border-radius。

改动之后 **report 里就有了** —— `unverifiable` 行照实带 `actual`，而它的算法与 probe 的 B) 半
**逐字同源**（都是 `effectiveRadius(numberFromPx(borderRadius), w, h)`）。

⇒ 「34 条 code 真画了圆角」从此可以**只读 report** 算出来，不需要 chromium。
⇒ `audit:render-silent-checks` 声明的那个盲区（「守不住 34，完整 probe 仍是 L1」）**具备了闭合条件**。

⛔ **本轮刻意不做那个闭合**：它要给那 34 条设计自己的棘轮 / 分档语义，会改动一条**已上线闸**的
判据含义 —— 属独立项（同 memory `root-cause-scope-creep`）。本轮只做到「让数据存在 + 证明它对得上」：
验收里逐条比对 report 的 `actual` 与 probe 产物的 `radiusEffective`（284 条全等 = 可派生性有硬证据，
不是声明）。

## 6. 如实登记的残余（⛔ 不是待补 TODO 清单，是边界）

1. **`textFillHex === null` 与 `rootBorderHex`（width 0）两处仍用 `pass-by-mode-skip` 表达「Figma 没说」** ——
   与本轮判据同类，但它们**今天就在发行**（不静默）⇒ 病性质不同（是「归错档」不是「看不见」）。
   本轮不动：改它会让 `passByModeSkip` / `passRate` / entry status 三个数一起动，
   而那正是本条判据刻意保持不变的三个数。⇒ 独立项。
2. **其余四个潜伏字段（`rootWidth` / `rootHeight` / `textInset.*`）今日 0 条** ⇒ 机制已覆盖，但
   「它们哪天变非 0」没有棘轮盯着（`audit:render-silent-checks` 的 baseline 只按 `rootRadius` 建）。
   可见性有了（report + summary `byField`），**拦不住**。这是边界。
3. **完整 probe 的 B) 半仍是 L1**（见 §5 最后一段）。

## 7. 验收（终态事实，⛔ 不接受「跑完没报错」这类代理判据）

改动前的**磁盘现值**（committed report，`checkedAt` 2026-08-20）：

| | Vue | React |
|---|---|---|
| entries | 936 | 930 |
| check 行总数 | 12012 | 11938 |
| `rootRadius` 行 | 652 | 650 |
| status 分布 | pass 10503 · skip 1252 · fail 257 | pass 10452 · skip 1232 · fail 254 |
| summary | pass 262 · skip 577 · fail 97 · rate 89.64% · A 0 · B 257 · excuse 33/33/0/257 | pass 262 · skip 574 · fail 94 · rate 89.89% · A 0 · B 254 · excuse 33/33/0/254 |

**可证伪的预测**（两条链都要满足，任一条不满足 = 改动弄坏了东西）：

1. `rootRadius` 行：Vue 652 → **936**（+284）· React 650 → **930**（+280）
2. 新 status `unverifiable`：Vue **284** · React **280**；`pass` / `pass-by-mode-skip` / `fail`
   三档的**行数逐字不变**
3. `summary` 的 `pass` / `passByModeSkip` / `fail` / `passRate` / `classifications` / `excuse.*`
   **一个数都不变**
4. `unverifiableChecks.byField` **只有 `rootRadius` 一个键**（证明 §2 那张表没漏字段）
5. Vue 那 284 条 `unverifiable` 行的 `actual`，与 `f129-rootradius-silent-gap.probe.json`
   同 `manifestId` 的 `radiusEffective` **逐条相等**（§5 的硬证据）
6. `pnpm audit:render-drift-gate` / `-react` / `audit:render-silent-checks` 三条闸 **exit 0**
7. **故障注入**：把 `pushNumber` 的新分支改回 `return` ⇒ 单测与新 S1 必须当场红（证明不是空过）

## 8. 实测结果（2026-08-21 落地当日 —— **7 项预测逐条命中**）

⛔ **别引用本节的数当现值**，重跑 `pnpm test:render-verification` + `-react` 现算。本节留的是「落地当日对得上」这个事实。

| # | 预测 | Vue 实测 | React 实测 |
|---:|---|---|---|
| 1 | `rootRadius` 行 +全部 null | 652 → **936** ✅ | 650 → **930** ✅ |
| 2 | 新 `unverifiable` 行数 · 另三档行数不变 | **284** · pass 10503 / skip 1252 / fail 257（**全等**）✅ | **280** · 10452 / 1232 / 254（**全等**）✅ |
| 3 | summary 六组数一个不变 | pass 262 · skip 577 · fail 97 · rate 0.8963675213675214 · A 0 / B 257 / C 0 · excuse 33/33/0/257 ✅ | 262 · 574 · 94 · 0.8989247311827957 · 0/254/0 · 33/33/0/254 ✅ |
| 4 | `byField` 只有 `rootRadius` | ✅ `{rootRadius: 284}` | ✅ `{rootRadius: 280}` |
| 5 | `actual` 与量尺 `radiusEffective` 逐条相等 | **284 / 284 全等，0 条不符**；据此只读 report 派生出 `SILENT_GAP=34 / NO_RADIUS=250`，与 chromium 实测**逐字相同** ✅ | （量尺产物是 Vue 链的，本项不适用） |
| 6 | 三条闸 exit 0 | `audit:render-silent-checks` 0 · `audit:render-drift-gate` 0 · `-react` 0 ✅ | 同 |
| 7 | 故障注入会红（配阴性对照） | 见下 ✅ | 同 |

**故障注入三组（全部配阴性对照 —— 没有对照的「红了」不算证据）**：

| 注入 | 结果 | 阴性对照 |
|---|---|---|
| `pushNumber` 的 null 分支改回 `return` | `tests/DriftCompareUnverifiable.test.ts` **16 条里 7 条红** | 还原后 16/16 绿 |
| report 里剥掉全部 `unverifiable` 行 | `audit:render-silent-checks` S1 **EXIT=1**，逐条点名 `want=unverifiable got=absent` | **同一条命令**对真 report **EXIT=0** |
| 删 `summary.unverifiableChecks` | 两条 render 闸 **S10 EXIT=1** | 恢复后 EXIT=0；且把数字改成 99999 **照样 EXIT=0**（钉「S10 刻意不判数字」）+ 必须自印出来 |

**顺带的两处副作用（都已处置，登记以免下一个人以为是漏改）**：
1. `tests/render-drift-gate-core.test.ts` 的 `baseReport()` 缺新字段 ⇒ **9 条红**。改 fixture + 补 3 条 S10 单测。⚠️ 与 memory `plan-fixture-is-not-the-criterion-source` **不冲突**：那条治「压缩样本与判据冲突时改实现」，这里是**真源 schema 真的多了必填字段、fixture 该跟**。
2. `audit:typecheck-scope` 的 `tests/**` 计数 155 → **156**（新单测；`growth: 'report'` 不拦，但记录值该跟上）。

**全套 vitest**：142 files / **1864 passed · 0 failed** · 11 skipped。**`vue-tsc --noEmit`**：0 错。
