# INFRA-F97 —— 给 render-gate 补「测量量」下界，让空跑变红

> **状态**：设计已 owner 认可（2026-08-05），转 `writing-plans`。
> **对应 backlog**：[[INFRA-F97]]（`docs/internal/backlog.md`）—— 实测两份 summary 对照表在那里，本文不复述它的**故障态**数字。
> **⚠️ 数字口径**：本文引用的**健康态基线**取自 2026-08-05 本机实跑（`checkedAt = 2026-08-05T03:25:11.588Z`，服务器由 playwright 自管、全程活着）。manifest 由 `generate-render-verification-manifest.mjs` 每次重生成，**条数会随 figma-data 变**；引用前自己重跑，别抄本文。

---

## 1. 缘起：闸的判据里没有一条是「测量量」

`audit:render-drift-gate` 现有判据只有两条（[`scripts/audit-render-drift-gate.mjs`](../../../scripts/audit-render-drift-gate.mjs)）：

1. `summary.classifications.A_TRUE_DRIFT_CANDIDATE ≤ BASELINE_A(0)`
2. `nodeCoverage.mismatches ≤ BASELINE_NODE_MISMATCH(0)`

两条都是**违例数的上界**。而导航失败**不产生任何 classification** —— [`tests/visual-verify/manifest-verifier.spec.ts:166-172`](../../../tests/visual-verify/manifest-verifier.spec.ts) 的 catch 块推的那条 check 只有 `field: 'navigation'` / `pass: false`，**没有 `classification` 字段**，而 `writeReports()` 统计 A/B/C 时先 `.filter((check) => check.classification)`。于是：

> 服务器中途消失 → 794 条 entry 的唯一 check 是 `ERR_CONNECTION_REFUSED` → A 仍然是 0 → **闸 PASS**。

`playwright` 那句 `1 passed` 同理不构成证据：整个 manifest 是**一个** test，逐条结果只进报告、不进断言，唯一的断言是 `expect(reports).toHaveLength(entries.length)`（= 「都被 push 过」，不是「都测到了」）。

**分母也不在这条链上**：`audit:render-verification-coverage`（要求每个 canonical ≥1 entry）挂在 pre-commit 条件触发 + `audit:self-audit-phase2`，**不在** render-gate 链上；render-gate 自己那句 `checked 936 entries` 只是 `console.log`，不是断言。

### 1.1 为什么这条洞是 High

`RELEASING.md` 把 render-gate 定为**切 tag 前的强制关卡**，且 [`scripts/release.mjs:68-71`](../../../scripts/release.mjs) 里**没有 skip flag**（`RELEASING.md:101` 明写⛔不许给 `release.mjs` 加 `--yes/--force`）。这个洞让「跑过了」不等于「测过了」，而 render-gate 恰恰是那条链上**唯一**做 Figma↔code 数值验证的关卡（它不在 `prepublishOnly` 里 —— 需要浏览器，见 `RELEASING.md:205`）。

### 1.2 触发路径不止本次那一条

本次的近因是一次误用 `nohup … &` 留下的孤儿 `pnpm dev`，被 `playwright.render-verification.config.ts` 的 `reuseExistingServer: true` 复用后中途消失。**但那只是近因**：「服务器起不来 / 中途死 / 端口被别的 session 占了又释放」都是同一形态，而本仓库明确是多 session 并行的。

⛔ **因此不许把「改 `reuseExistingServer`」当成修完** —— 那只堵住本次这一条触发路径，判据仍然看不见空跑。

---

## 2. 判据（四条，全落在闸里）

| # | 断言 | 抓的故障 | 2026-08-05 基线 |
|---|---|---|---|
| **S1** | `summary.total > 0` | 空 manifest / 空 report。**没有这条，S4 会以 `0 === 0` 通过** | `936 > 0` ✓ |
| **S2** | `summary.total` === 磁盘 `figma-data/render-verification-manifest.json` 的条目数 | report 陈旧 / 只覆盖了 manifest 的一部分 | `936 === 936` ✓ |
| **S3** | `summary.navigationFailures === 0` | **服务器死 / 起不来 / 端口被抢**（F97 本体） | `0` ✓ |
| **S4** | `summary.measuredEntries === summary.total` | 导航成功但该 entry 一条真测量都没产出 | `936 === 936` ✓ |

**fail closed**：上述任一字段缺失或不是 `number` → FAIL（照抄现有 `A_TRUE_DRIFT_CANDIDATE` 那处 `typeof !== 'number'` 即 `exit 1` 的范式）；manifest 文件读不到或不是数组 → FAIL。四条的实测值**每次运行自印**（仓库惯例：各闸覆盖面在脚本头注释且每次运行自印）。

### 2.1 为什么是这四条 —— 逃逸方向分析

选判据的问法是「**被规避时内容流向哪**」，不是「谁更严」：

| 候选 | 逃逸方向 | 结论 |
|---|---|---|
| `passRate` 不低于具名 baseline | 少测一点（把难的 entry 从 manifest 拿掉）就能抬高 passRate | ❌ 能被蒙混 |
| 分类总数 A+B+C 不为 0 | 同上，且只要有**一条** entry 产出分类就满足 | ❌ 能被蒙混 |
| **导航失败数 = 0（S3）** | 单独用时可被「manifest 缩水」蒙混（3 条 entry 全通 → 0 失败） | ⚠️ 必须配 S1+S2 |
| **分母对齐（S2）** | 要规避得同时改磁盘 manifest —— 而那是生成物，改了下次 `test:render-verification` 就重生成回来 | ✅ |

⇒ S1+S2 封住分母，S3+S4 封住「分母非零但没真测」。四条合起来才没有留「少测一点」的口子。

### 2.2 不写死任何 baseline 数字

S2/S4 是**两个活源比对**（report ↔ 磁盘 manifest；measuredEntries ↔ total），S1/S3 是**0 与非零**的定性判断。**没有一条形如「记住 936」** —— 符合 F97 entry 的 ⛔②（别把 baseline 数字写死进闸，那会随 figma-data 漂）。

### 2.3 余量核实（不可满足的闸 = 没有闸）

四条在 2026-08-05 健康态基线上**违例数全为 0**（上表右列），即闸落地当天即可全绿，不需要任何豁免表。这是本条与 [[INFRA-F95]]/[[INFRA-F96]] 的差别 —— 那两条落地时有存量缺陷需要 shrink-only 豁免，本条没有。

---

## 3. 测量量在哪算 —— 字面量只许有一处

三条路：① spec 算好数字、闸只读数字 ② 闸自己走 `report.entries` 派生 ③ 两处都算并交叉核对。

**选 ①**。理由：

1. `'navigation'` 这个字面量**只在产生那条 check 的文件里出现**。闸压根不需要知道「什么算基础设施失败」，只做数字比较 —— 与它现在读 `summary.classifications`（不自己分类）**是同一形态**。
2. 选 ② 会让「什么算真测量」在两个文件各写一遍：将来加第二种基础设施失败（如 selector 解析不到）时，闸会把它当成真测量 → **静默失效**（反模式 #5：好机制扩展只改一处）。
3. ③ 是 YAGNI —— 它只多抓「report 被手工改过」，而那不是登记在案的故障形态。

### 3.1 具体形态

`writeReports()` 里现在是一坨内联统计。把**统计**抽成纯函数放进 `tests/visual-verify/lib/`（与 `drift-compare-core.ts` 同级），spec 调它：

```
summarize(reports: EntryReport[]): {
  total, pass, passByModeSkip, fail, passRate, classifications,   // 现有字段，逐字不变
  navigationFailures,   // 新增
  measuredEntries,      // 新增
}
```

- `navigationFailures` = 含 `field === NAVIGATION_FIELD && !pass` 的 check 的 **entry 数**
- `measuredEntries` = 含 **≥1 个** `field !== NAVIGATION_FIELD` 的 check 的 entry 数
- `NAVIGATION_FIELD` 是**单一导出常量**，catch 块（生产那条 check 的地方）与统计**共用同一个** —— 全仓该字面量只有一处

**约束**：抽函数是**纯搬运**，现有六个字段的算法逐字不变（`passRate` 的 `reports.length ? … : 0` 分母保护照旧保留）。新增两个字段与 `nodeCoverage`（report-only，不入 A/B/C、不能翻转 entry status）的既有边界互不影响。

⚠️ **`summarize()` 刻意不接 `manifestLength`，S2 必须由闸读磁盘 manifest 来做**：spec 侧已有 `expect(reports).toHaveLength(entries.length)`，在 spec 里再比一次就是**空过**（`reports` 与 `entries` 同一次运行内恒等）。S2 的价值恰在于它是**跨运行**的比对 —— report 是上一次跑的产物、manifest 是此刻磁盘上的真源，两者不符即说明 report 陈旧或只覆盖了一部分。

两个新字段同时进 `summary` **和** markdown 报告的 Summary 表 —— 人跑一次就看得见「导航失败 0」，观察面不依赖闸的 stdout。

---

## 4. 验收（两层，缺一不算）

### 4.1 单测（对 `summarize` 纯函数）

放 `tests/` 下走 vitest。必须含的 case：

| case | fixture | 断言 |
|---|---|---|
| 健康态 | 每条 entry 有多个真测量 check | `navigationFailures = 0` · `measuredEntries = total` |
| 全空跑 | 每条 entry 只有一条 navigation 失败 check | `navigationFailures = N` · `measuredEntries = 0` |
| 混合 | 一部分导航失败、一部分正常 | 两个数各自等于对应的 entry 数 |
| 空输入 | `reports = []` | `total = 0` · 不抛 |
| 现有字段回归 | 同一份 fixture | 六个现有字段与抽函数前逐字相同 |

⚠️ **单测绿不能替代 §4.2** —— 压缩 fixture 会改判据测量的量，照它改可能开逃逸口。

### 4.2 真仓库端到端故障注入（**必做**）

| 故障 | 造法 | 期望 |
|---|---|---|
| **服务器不可用**（F97 本体，**必须真跑一次**） | 先 `lsof` 确认 5173 无监听；用**放在 scratchpad 的一份临时 playwright config**（删掉 `webServer` 段、`baseURL` 指向该无监听端口，`testDir` 仍指仓库里那份 spec）跑一次 → 得到导航全失败的真 report → **单独跑闸** | 闸 **FAIL**，且 stdout 指名是导航失败、印出实测数 |
| **分母不对齐（S2）** | 在一份健康 report 旁把磁盘 manifest 删掉若干条 → 单独跑闸 | 闸 **FAIL**，指名 report/manifest 条数不符 |
| **字段缺失（fail closed）** | 从一份健康 report 里删掉 `navigationFailures` → 单独跑闸 | 闸 **FAIL**（不是「当 0 处理」后放行） |
| **`total = 0`（S1）** | 把 report 的 `entries` 清空、`summary.total` 改 0，manifest 同步清空（使 S2 也成立） → 单独跑闸 | 闸 **FAIL**（S1 拦住，不让 `0 === 0` 蒙过 S4） |
| **阴性对照** | 健康 report + 未改动 manifest | 闸 **PASS**，且四条实测值全部印出 |

**造故障的纪律**：

- ⛔ **临时 config 只许放 scratchpad，不许改仓库里那两个 playwright config**（否则就变成 §5 禁的「碰 `reuseExistingServer`」）。
- 闸的 report 路径是硬编码的、**刻意不加 env 覆盖**（那是逃逸口：能指向一份假的健康 report）。所以故障注入的做法是：**先把健康产物记下来**，就地改 `figma-data/normalized/render-verification.report.json` / `render-verification-manifest.json`，跑完用 `git checkout -- <两个路径>` 精确还原（三份产物均 git-tracked，2026-08-05 已核）。
- 每次注入完必须 `git status` 确认工作树回到注入前状态，再做下一条。
- ⛔ **探针名先 `grep` 全仓确认零命中** —— 2026-08-05 实证：探针名写在 plan 里会让它自引用，闸不红看起来像判据有洞。
- ⛔ 起后台任务**不要**在 `run_in_background` 里再套 `nohup … &`（双重后台，exit 0 只表示「后台化成功」，进程会被回收）—— 这正是 F97 那次孤儿 `pnpm dev` 的成因。

---

## 5. 不做的

- ⛔ **不碰 `reuseExistingServer`**（§1.2）
- ⛔ **不碰任何 `.css` / `.vue`**（本条工作预计完全不需要；若发现需要则先停，`VISUAL_COMMIT_APPROVED` 由 owner 给）
- ⛔ **不动 `BASELINE_A` / `BASELINE_NODE_MISMATCH`**，也不动现有两条判据的语义
- ⛔ **不把 `nodeCoverage` 拉进判据** —— 它是 report-only，`gaps=2739` 是已登记的正常态（由 `audit:render-coverage-gaps` 单独跟踪）
- **`checkedAt` 新鲜度断言不在本轮**：它能抓「manifest 没变、单独跑闸吃一份旧的健康 report」，S2 盖不住。但它需要定一个「多旧算旧」的阈值 = 独立的判据设计问题（阈值可争）。⇒ 本 spec 执行完后**立独立 entry**，不塞进本轮。
- **不并入 [[INFRA-F86]]**（`test:a11y` 红）—— 那条的阻塞是 `color-contrast`，与本条无关。

---

## 6. 涉及文件（预期）

| 文件 | 改动性质 |
|---|---|
| `tests/visual-verify/lib/render-report-summary.ts`（新） | `summarize()` + `NAVIGATION_FIELD` 常量 |
| `tests/visual-verify/manifest-verifier.spec.ts` | 内联统计换成调 `summarize()`；catch 块用同一常量；markdown Summary 表加两列 |
| `scripts/audit-render-drift-gate.mjs` | 加 S1–S4 + fail-closed + 自印；头注释登记新覆盖面 |
| `tests/render-report-summary.test.ts`（新） | §4.1 单测 |
| `docs/internal/backlog.md` | F97 entry 收口（判据是什么 / 实测证据 / 新鲜度那条另立） |

`package.json` 无需改动 —— 判据落在既有 `audit:render-drift-gate` 里，四个调用面（`release.mjs` / `gates.yml` / `ci.yml` / 手跑）同时受益，不新增脚本入口。
