# INFRA-F97 render-gate「测量量」下界 Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 给 `audit:render-drift-gate` 补四条「测量量」判据（S1–S4），让「服务器死了 / 分母缩水 / 分母非零但一条都没真测」的空跑从 PASS 变 FAIL。

**Architecture:** 测量量在 **spec 侧**算好写进 `summary`，闸只读数字做比较 —— `'navigation'` 这个字面量全仓只有一处（`NAVIGATION_FIELD` 常量），闸不需要知道「什么算基础设施失败」。`writeReports()` 里那坨内联统计抽成纯函数 `summarize()` 进 `tests/visual-verify/lib/`，与 `drift-compare-core.ts` 同级，可单测。

**Tech Stack:** TypeScript（`tests/visual-verify/lib/`，被 Playwright 与 vitest 双跑）· Node ESM（`.mjs` 闸脚本，零依赖）· vitest 1.5（`include: ['tests/**/*.{test,spec}.{ts,mts}']`）· Playwright（`playwright.render-verification.config.ts`）

**判据真源 = [`specs/2026-08-05-infra-f97-render-gate-measurement-floor-design.md`](../specs/2026-08-05-infra-f97-render-gate-measurement-floor-design.md)。本计划冲突时以 spec 为准 —— 尤其 fixture：本计划内联的 fixture 若与 spec 判据冲突，改 fixture 不改实现（[[feedback_plan-fixture-is-not-the-criterion-source]]）。**

## Global Constraints

以下逐字取自 spec §5「不做的」+ §4「造故障的纪律」，每个 Task 隐含包含：

- ⛔ **不碰 `reuseExistingServer`**（改它只堵一条触发路径，判据仍看不见空跑）
- ⛔ **不碰任何 `.css` / `.vue`** —— 若发现需要则**先停**，`VISUAL_COMMIT_APPROVED` 由 owner 给，AI 不自设
- ⛔ **不动 `BASELINE_A` / `BASELINE_NODE_MISMATCH`**，不动现有两条判据的语义
- ⛔ **不把 `nodeCoverage` 拉进判据** —— 它 report-only，`gaps` 非零是已登记正常态（`audit:render-coverage-gaps` 单独跟踪）
- ⛔ **闸的 report 路径硬编码，刻意不加 env 覆盖**（那是逃逸口：能指向一份假的健康 report）
- ⛔ **临时 playwright config 只许放 scratchpad**，不许改仓库里那两个 config
- ⛔ **探针名先 `grep` 全仓确认零命中**（探针名写进 plan 会让它自引用，闸不红看起来像判据有洞）
- ⛔ **起后台任务不要在 `run_in_background` 里再套 `nohup … &`**（双重后台，进程会被回收 —— 这正是 F97 那次孤儿 `pnpm dev` 的成因）
- ⛔ **不写死任何 baseline 数字进闸** —— S2/S4 是两个活源比对，S1/S3 是 0 与非零的定性判断
- **commit 纪律**：`git commit -F <msg 文件> -- <路径逐条内联>`（`-F` 在 `--` 之前），提交后紧跟 `git reset -- <同样路径>`；push 落地只信 `git ls-remote`。pre-commit 跑全量 vitest → **前台 commit 必撞 2min 超时**，commit/push 一律 `run_in_background`
- **本机 `timeout` 命令不存在**（zsh，`command not found: timeout`）—— 别用它包命令

---

## File Structure

| 文件 | 职责 | 性质 |
|---|---|---|
| `tests/visual-verify/lib/render-report-summary.ts` | `NAVIGATION_FIELD` 常量 · `collectClassifiedChecks()` · `summarize()` 纯函数 | **新建** |
| `tests/render-report-summary.test.ts` | `summarize()` 单测（spec §4.1 五个 case） | **新建** |
| `tests/visual-verify/manifest-verifier.spec.ts` | 内联统计换成调 `summarize()`；catch 块用 `NAVIGATION_FIELD`；markdown Summary 表加两列 | 改 `:13-140`、`:166-172` |
| `scripts/audit-render-drift-gate.mjs` | 加 S1–S4 + fail-closed + 四条实测值自印；头注释登记新覆盖面 | 改（追加，不动现有两条） |
| `docs/internal/backlog.md` | F97 entry 收口 + 立「`checkedAt` 新鲜度」独立 entry | 改 |
| `docs/STATUS.md` / tracker | wrap-up 同步 | 改 |

**为什么 lib 里多导出一个 `collectClassifiedChecks`（spec §3.1 只列了 `summarize` + `NAVIGATION_FIELD`）**：现有 `writeReports()` 里 `classifiedChecks` 这个 `{entry, check}` 数组被**两处**消费 —— A/B/C 计数 + markdown「Classification Samples」取样。统计搬进 `summarize()` 后，samples 那处仍需要它。若不导出，`.filter((check) => check.classification)` 这个「什么算已分类 check」的定义会在两个文件各写一遍（= spec §3 理由 2 要避免的形态）。**这是对 spec 的补充不是偏离**，spec 未禁止额外导出。

---

## Task 1: `summarize()` 纯函数 + 单测

**Files:**
- Create: `tests/visual-verify/lib/render-report-summary.ts`
- Create: `tests/render-report-summary.test.ts`

**Interfaces:**
- Consumes: `EntryReport` / `CheckResult` type from `tests/visual-verify/lib/drift-compare-core.ts`（已存在，`:82-105`）
- Produces（Task 2、Task 3 依赖这些确切名字）：
  - `NAVIGATION_FIELD: 'navigation'`（常量）
  - `collectClassifiedChecks(reports: EntryReport[]): { entry: EntryReport; check: CheckResult }[]`
  - `summarize(reports: EntryReport[]): RenderReportSummary`
  - `RenderReportSummary` 类型，字段：`total` `pass` `passByModeSkip` `fail` `passRate` `classifications{A_TRUE_DRIFT_CANDIDATE,B_RESIDUAL_SCHEMA_GAP,C_BOUNDARY_CASE}` `navigationFailures` `measuredEntries`

- [ ] **Step 1: 写失败的单测**

Create `tests/render-report-summary.test.ts`:

```ts
import { describe, it, expect } from 'vitest'
import type { EntryReport, CheckResult } from './visual-verify/lib/drift-compare-core'
import {
  NAVIGATION_FIELD,
  collectClassifiedChecks,
  summarize,
} from './visual-verify/lib/render-report-summary'

/**
 * fixture builder —— 只填 summarize() 真正读的字段，其余用类型要求的最小值。
 * ⚠️ 这些 fixture 是**判据的测量对象**，不是判据本身。与 spec 冲突时改 fixture。
 */
function entry(
  manifestId: string,
  status: EntryReport['status'],
  checks: CheckResult[],
): EntryReport {
  return {
    manifestId,
    figmaNodeId: '1:1',
    figmaName: 'Fixture',
    figmaVariantName: 'Fixture/variant',
    codeComponent: 'Fixture',
    codeProps: {},
    status,
    checks,
  }
}

function navFailCheck(): CheckResult {
  return {
    field: NAVIGATION_FIELD,
    expected: 'route loaded',
    actual: 'page.goto: net::ERR_CONNECTION_REFUSED at http://localhost:5173/x',
    pass: false,
    status: 'fail',
  }
}

function realCheck(
  field: string,
  pass: boolean,
  classification?: CheckResult['classification'],
): CheckResult {
  return {
    field,
    expected: '10px',
    actual: pass ? '10px' : '12px',
    pass,
    status: pass ? 'pass' : 'fail',
    ...(classification ? { classification } : {}),
  }
}

describe('summarize', () => {
  it('健康态：每条 entry 都有真测量 check → navigationFailures=0, measuredEntries=total', () => {
    const reports = [
      entry('a', 'PASS', [realCheck('rootWidth', true), realCheck('rootHeight', true)]),
      entry('b', 'PASS', [realCheck('rootWidth', true)]),
      entry('c', 'FAIL', [realCheck('rootFillHex', false, 'A_TRUE_DRIFT_CANDIDATE')]),
    ]
    const s = summarize(reports)
    expect(s.total).toBe(3)
    expect(s.navigationFailures).toBe(0)
    expect(s.measuredEntries).toBe(3)
  })

  it('全空跑：每条 entry 只有一条导航失败 check → navigationFailures=N, measuredEntries=0', () => {
    const reports = [
      entry('a', 'FAIL', [navFailCheck()]),
      entry('b', 'FAIL', [navFailCheck()]),
      entry('c', 'FAIL', [navFailCheck()]),
    ]
    const s = summarize(reports)
    expect(s.total).toBe(3)
    expect(s.navigationFailures).toBe(3)
    expect(s.measuredEntries).toBe(0)
    // F97 本体：A 分类恒 0，所以旧闸看不见 —— 这里钉住那个事实
    expect(s.classifications.A_TRUE_DRIFT_CANDIDATE).toBe(0)
  })

  it('混合：两个数各自等于对应的 entry 数', () => {
    const reports = [
      entry('a', 'FAIL', [navFailCheck()]),
      entry('b', 'PASS', [realCheck('rootWidth', true)]),
      entry('c', 'FAIL', [navFailCheck()]),
      entry('d', 'FAIL', [realCheck('rootGap', false, 'B_RESIDUAL_SCHEMA_GAP')]),
    ]
    const s = summarize(reports)
    expect(s.total).toBe(4)
    expect(s.navigationFailures).toBe(2)
    expect(s.measuredEntries).toBe(2)
  })

  it('空输入：total=0 且不抛（passRate 分母保护）', () => {
    const s = summarize([])
    expect(s.total).toBe(0)
    expect(s.passRate).toBe(0)
    expect(s.navigationFailures).toBe(0)
    expect(s.measuredEntries).toBe(0)
    expect(s.classifications.A_TRUE_DRIFT_CANDIDATE).toBe(0)
  })

  it('现有六个字段与抽函数前逐字相同（算法回归）', () => {
    const reports = [
      entry('a', 'PASS', [realCheck('rootWidth', true)]),
      entry('b', 'PASS_BY_MODE_SKIP', [
        { field: 'rootFillHex', expected: null, actual: null, pass: true, status: 'pass-by-mode-skip', reason: 'mode skip' },
      ]),
      entry('c', 'FAIL', [
        realCheck('rootFillHex', false, 'A_TRUE_DRIFT_CANDIDATE'),
        realCheck('rootGap', false, 'B_RESIDUAL_SCHEMA_GAP'),
        realCheck('rootPaddingTop', false, 'C_BOUNDARY_CASE'),
      ]),
      entry('d', 'FAIL', [realCheck('rootHeight', false, 'C_BOUNDARY_CASE')]),
    ]
    const s = summarize(reports)
    expect(s.total).toBe(4)
    expect(s.pass).toBe(1)
    expect(s.passByModeSkip).toBe(1)
    expect(s.fail).toBe(2)
    expect(s.passRate).toBe(0.5) // (pass 1 + passByModeSkip 1) / 4
    expect(s.classifications).toEqual({
      A_TRUE_DRIFT_CANDIDATE: 1,
      B_RESIDUAL_SCHEMA_GAP: 1,
      C_BOUNDARY_CASE: 2,
    })
  })

  it('导航失败 check 若 pass=true 不计入 navigationFailures（判据是 field + !pass 两半）', () => {
    const reports = [
      entry('a', 'PASS', [
        { field: NAVIGATION_FIELD, expected: 'route loaded', actual: 'route loaded', pass: true, status: 'pass' },
        realCheck('rootWidth', true),
      ]),
    ]
    const s = summarize(reports)
    expect(s.navigationFailures).toBe(0)
    expect(s.measuredEntries).toBe(1)
  })

  it('同一 entry 多条导航失败 check 只算一个 entry（单位是 entry 不是 check）', () => {
    const reports = [entry('a', 'FAIL', [navFailCheck(), navFailCheck()])]
    const s = summarize(reports)
    expect(s.navigationFailures).toBe(1)
    expect(s.measuredEntries).toBe(0)
  })
})

describe('collectClassifiedChecks', () => {
  it('只收有 classification 的 check，并保留 entry 关联', () => {
    const reports = [
      entry('a', 'PASS', [realCheck('rootWidth', true)]),
      entry('b', 'FAIL', [
        realCheck('rootFillHex', false, 'A_TRUE_DRIFT_CANDIDATE'),
        realCheck('rootGap', true),
      ]),
    ]
    const pairs = collectClassifiedChecks(reports)
    expect(pairs).toHaveLength(1)
    expect(pairs[0].entry.manifestId).toBe('b')
    expect(pairs[0].check.classification).toBe('A_TRUE_DRIFT_CANDIDATE')
  })
})
```

- [ ] **Step 2: 跑单测确认失败**

Run: `pnpm vitest run tests/render-report-summary.test.ts`
Expected: FAIL —— `Failed to resolve import "./visual-verify/lib/render-report-summary"`

- [ ] **Step 3: 写实现**

Create `tests/visual-verify/lib/render-report-summary.ts`:

```ts
// INFRA-F97: render-verification report 的统计层（从 manifest-verifier.spec.ts 的
// writeReports() 内联统计抽出，便于单测 + 让 `audit:render-drift-gate` 有「测量量」可读）。
//
// 为什么测量量在这里算、闸只读数字（spec §3）：
//   'navigation' 这个字面量只在**产生**那条 check 的地方出现（manifest-verifier.spec.ts
//   的 catch 块）与这里的统计共用同一个 NAVIGATION_FIELD 常量 —— 全仓一处。闸压根不需要
//   知道「什么算基础设施失败」，只做数字比较，与它现在读 summary.classifications
//   （不自己分类）是同一形态。
//   ⛔ 别改成让闸自己走 report.entries 派生：那会让判据在两个文件各写一遍，将来加第二种
//   基础设施失败（如 selector 解析不到）时闸会把它当成真测量 → 静默失效。
//
// ⚠️ summarize() 刻意不接 manifestLength —— S2（report 条数 === 磁盘 manifest 条数）必须
//   由闸读磁盘来做。spec 侧已有 expect(reports).toHaveLength(entries.length)，在这里再比
//   一次是空过（同一次运行内两者恒等）；S2 的价值在**跨运行**比对。
import type { EntryReport, CheckResult } from './drift-compare-core'

/**
 * 导航失败 check 的 field 名。**全仓唯一定义** —— 产生该 check 的
 * manifest-verifier.spec.ts catch 块与本文件的统计共用它。
 */
export const NAVIGATION_FIELD = 'navigation'

export type RenderReportSummary = {
  total: number
  pass: number
  passByModeSkip: number
  fail: number
  passRate: number
  classifications: {
    A_TRUE_DRIFT_CANDIDATE: number
    B_RESIDUAL_SCHEMA_GAP: number
    C_BOUNDARY_CASE: number
  }
  /** 至少有一条 `field === NAVIGATION_FIELD && !pass` 的 check 的 **entry 数** */
  navigationFailures: number
  /** 至少有一条 `field !== NAVIGATION_FIELD` 的 check 的 **entry 数** */
  measuredEntries: number
}

/**
 * 已分类 check 与其所属 entry 的配对。计数与 markdown 取样共用同一个定义，
 * 避免「什么算已分类 check」在两个文件各写一遍。
 */
export function collectClassifiedChecks(
  reports: EntryReport[],
): { entry: EntryReport; check: CheckResult }[] {
  return reports.flatMap((entry) =>
    entry.checks
      .filter((check) => check.classification)
      .map((check) => ({ entry, check })),
  )
}

export function summarize(reports: EntryReport[]): RenderReportSummary {
  const pass = reports.filter((entry) => entry.status === 'PASS').length
  const passByModeSkip = reports.filter((entry) => entry.status === 'PASS_BY_MODE_SKIP').length
  const fail = reports.filter((entry) => entry.status === 'FAIL').length
  const classifiedChecks = collectClassifiedChecks(reports)
  return {
    total: reports.length,
    pass,
    passByModeSkip,
    fail,
    passRate: reports.length ? (pass + passByModeSkip) / reports.length : 0,
    classifications: {
      A_TRUE_DRIFT_CANDIDATE: classifiedChecks.filter(({ check }) => check.classification === 'A_TRUE_DRIFT_CANDIDATE').length,
      B_RESIDUAL_SCHEMA_GAP: classifiedChecks.filter(({ check }) => check.classification === 'B_RESIDUAL_SCHEMA_GAP').length,
      C_BOUNDARY_CASE: classifiedChecks.filter(({ check }) => check.classification === 'C_BOUNDARY_CASE').length,
    },
    navigationFailures: reports.filter((entry) =>
      entry.checks.some((check) => check.field === NAVIGATION_FIELD && !check.pass),
    ).length,
    measuredEntries: reports.filter((entry) =>
      entry.checks.some((check) => check.field !== NAVIGATION_FIELD),
    ).length,
  }
}
```

- [ ] **Step 4: 跑单测确认通过**

Run: `pnpm vitest run tests/render-report-summary.test.ts`
Expected: PASS，9 个 test

- [ ] **Step 5: 类型检查**

Run: `pnpm vue-tsc --noEmit`
Expected: exit 0

- [ ] **Step 6: Commit**（后台跑，pre-commit 全量 vitest 会超前台 2min）

msg 文件写到 scratchpad，然后：

```bash
git commit -F <msg路径> -- tests/visual-verify/lib/render-report-summary.ts tests/render-report-summary.test.ts
git reset -- tests/visual-verify/lib/render-report-summary.ts tests/render-report-summary.test.ts
```

---

## Task 2: spec 侧接入 `summarize()` + markdown 加两列 + 产出健康态 report

**Files:**
- Modify: `tests/visual-verify/manifest-verifier.spec.ts:1-11`（import）、`:13-140`（`writeReports`）、`:166-172`（catch 块）

**Interfaces:**
- Consumes: Task 1 的 `NAVIGATION_FIELD` / `collectClassifiedChecks` / `summarize`
- Produces: `figma-data/normalized/render-verification.report.json` 的 `summary` 多两个字段 `navigationFailures` / `measuredEntries` —— Task 3 的闸读它们

- [ ] **Step 1: 改 import 段**

`tests/visual-verify/manifest-verifier.spec.ts:1-11`，在 `collectActual` 那行 import 之后加一行：

```ts
import { NAVIGATION_FIELD, collectClassifiedChecks, summarize } from './lib/render-report-summary'
```

- [ ] **Step 2: `writeReports()` 头部换成调 `summarize()`**

把 `:14-26` 这段（`const pass` 到 `classificationCounts` 那个对象闭合）整段替换为：

```ts
  const summary = summarize(reports)
  const classifiedChecks = collectClassifiedChecks(reports)
```

`nodeCoverage` 那段（`:27-59`）**原样不动**。

- [ ] **Step 3: `payload` 用新 summary**

把 `:60-72` 的 `payload` 替换为：

```ts
  const payload = {
    checkedAt: new Date().toISOString(),
    summary,
    nodeCoverage,
    entries: reports,
  }
```

- [ ] **Step 4: markdown Summary 表加两列**

把 `:84-86` 三行替换为（新增 `Nav failures` / `Measured entries` 两列；⚠️ 原来引用的裸变量 `pass`/`passByModeSkip`/`fail` 已随 Step 2 删除，必须改成 `summary.*`）：

```ts
    `| Total | PASS | PASS_BY_MODE_SKIP | FAIL | Pass rate | Nav failures | Measured entries |`,
    `|---:|---:|---:|---:|---:|---:|---:|`,
    `| ${summary.total} | ${summary.pass} | ${summary.passByModeSkip} | ${summary.fail} | ${(summary.passRate * 100).toFixed(1)}% | ${summary.navigationFailures} | ${summary.measuredEntries} |`,
```

- [ ] **Step 5: `grep` 确认没有遗漏的裸变量引用**

Run: `grep -n 'payload\.summary\|\${pass}\|\${fail}\|\${passByModeSkip}' tests/visual-verify/manifest-verifier.spec.ts`
Expected: 无输出（`:88-90` 的 classifications 那三行原本用 `payload.summary.classifications.*`，也要一并改成 `summary.classifications.*`；若 grep 有命中就是漏了）

若 `:88-90` 仍是 `payload.summary.classifications.X`，替换为 `summary.classifications.X`（三处）。

- [ ] **Step 6: catch 块用常量**

`tests/visual-verify/manifest-verifier.spec.ts:167`，把 `field: 'navigation',` 改成：

```ts
            field: NAVIGATION_FIELD,
```

- [ ] **Step 7: 确认 `'navigation'` 字面量全仓只剩一处**

Run: `grep -rn "'navigation'" tests/ scripts/ --include=*.ts --include=*.mjs`
Expected: 只有 `tests/visual-verify/lib/render-report-summary.ts` 里 `NAVIGATION_FIELD` 的定义那一行

- [ ] **Step 8: 类型检查 + 全量单测**

Run: `pnpm vue-tsc --noEmit && pnpm vitest run`
Expected: 两条都 exit 0（`manifest-verifier.spec.ts` 在 vitest 下走 `process.env.VITEST` 分支 `test.skip`，不会真跑浏览器）

- [ ] **Step 9: 跑真的 render-verification 产出健康态 report**（**后台**，约数分钟）

先确认 5173 无残留监听（避免复用别的 session 的 server —— 那正是 F97 的成因）：

Run: `lsof -nP -iTCP:5173 -sTCP:LISTEN`
Expected: 无输出

然后 `run_in_background` 跑：`pnpm test:render-verification`
⛔ 不要在里面套 `nohup … &`。

- [ ] **Step 10: 核实健康态 report 真的健康**

Run:
```bash
node -e "const r=require('./figma-data/normalized/render-verification.report.json');console.log(JSON.stringify(r.summary,null,2));console.log('checkedAt',r.checkedAt)"
```
Expected: `navigationFailures: 0` · `measuredEntries === total` · `total > 0`（2026-08-05 基线是 936，⚠️ **以本次实跑为准，不抄**）

Run: `pnpm audit:render-drift-gate`
Expected: PASS（此时闸还是旧的两条判据 —— 这是 Task 3 的**前置阴性对照**）

- [ ] **Step 11: Commit**

```bash
git commit -F <msg路径> -- tests/visual-verify/manifest-verifier.spec.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
git reset -- tests/visual-verify/manifest-verifier.spec.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
```

---

## Task 3: 闸加 S1–S4 + fail-closed + 自印，并做四组廉价故障注入

**Files:**
- Modify: `scripts/audit-render-drift-gate.mjs`（头注释 + 追加判据；⛔ 不动 `BASELINE_A` / `BASELINE_NODE_MISMATCH` 与现有两条）

**Interfaces:**
- Consumes: Task 2 产出的 `summary.total` / `summary.navigationFailures` / `summary.measuredEntries` + 磁盘 `figma-data/render-verification-manifest.json`
- Produces: 闸的 stdout 自印四条实测值；FAIL 时逐条点名

- [ ] **Step 1: 头注释登记新覆盖面**

在 `scripts/audit-render-drift-gate.mjs:14`（`//   pnpm test:render-verification && pnpm audit:render-drift-gate` 那行）之后插入：

```js
//
// INFRA-F97（2026-08-05）：补「测量量」下界。此前两条判据都是**违例数的上界**
// （A ≤ 0 / node mismatches ≤ 0），而导航失败不产生任何 classification ——
// 服务器中途死掉、794/936 条 ERR_CONNECTION_REFUSED 的那次跑，A 仍是 0 → 闸 PASS。
// **分母非零 ≠ 真测了**。四条新判据：
//   S1 summary.total > 0                       （空 manifest / 空 report；没它 S4 会以 0===0 通过）
//   S2 summary.total === 磁盘 manifest 条目数    （report 陈旧 / 只覆盖了一部分）
//   S3 summary.navigationFailures === 0        （服务器死 / 起不来 / 端口被抢 —— F97 本体）
//   S4 summary.measuredEntries === total       （导航成功但一条真测量都没产出）
// 任一字段缺失或非 number → FAIL（fail closed）；manifest 读不到或不是数组 → FAIL。
// ⛔ 四条**没有一条写死 baseline** —— S2/S4 是活源比对，S1/S3 是 0 与非零的定性判断。
// ⛔ 覆盖面边界：本闸不判 report 的**新鲜度**（manifest 没变时，单独跑闸会吃一份旧的
//    健康 report 而照样 PASS）。那需要定「多旧算旧」的阈值 = 独立判据设计，另立 entry。
```

- [ ] **Step 2: 加 manifest 读取 + fail-closed**

在 `scripts/audit-render-drift-gate.mjs:52`（`const nodeMismatches = …` 那行）**之前**插入：

```js
// INFRA-F97 S2：分母的第二个活源。刻意读磁盘 manifest 而不是信 report 自报的 total ——
// report 是上一次跑的产物、manifest 是此刻磁盘上的真源，两者不符即说明 report 陈旧
// 或只覆盖了一部分。（在 spec 侧比是空过：同一次运行内 reports 与 entries 恒等。）
const manifestPath = path.resolve('figma-data/render-verification-manifest.json')

if (!fs.existsSync(manifestPath)) {
  console.error(
    `✗ render-drift-gate: 找不到 ${manifestPath}\n` +
      '  它由 `node scripts/generate-render-verification-manifest.mjs` 生成（`pnpm test:render-verification` 会先跑它）。',
  )
  process.exit(1)
}

const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))

if (!Array.isArray(manifest)) {
  console.error('✗ render-drift-gate: manifest 不是数组（结构变了？）—— fail closed，不放行')
  process.exit(1)
}

const summary = report.summary ?? {}
const total = summary.total
const navigationFailures = summary.navigationFailures
const measuredEntries = summary.measuredEntries

for (const [field, value] of [
  ['summary.total', total],
  ['summary.navigationFailures', navigationFailures],
  ['summary.measuredEntries', measuredEntries],
]) {
  if (typeof value !== 'number') {
    console.error(
      `✗ render-drift-gate: report 缺 ${field} 字段或不是 number（实际 ${typeof value}）。\n` +
        '  这三个字段由 tests/visual-verify/lib/render-report-summary.ts 的 summarize() 产出；\n' +
        '  report 是旧格式就重跑 `pnpm test:render-verification`。fail closed，不当 0 处理。',
    )
    process.exit(1)
  }
}
```

- [ ] **Step 3: 加 S1–S4 判据**

在 `scripts/audit-render-drift-gate.mjs` 的 `let failed = false` 之后、`if (actualA > BASELINE_A)` 之前插入：

```js
// ── INFRA-F97 测量量下界（S1–S4）─────────────────────────────────────────
if (total <= 0) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S1): summary.total=${total} —— report 里一条 entry 都没有。\n` +
      '  空 report / 空 manifest 不算「测过了」。',
  )
}

if (total !== manifest.length) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S2): report 条数=${total} ≠ 磁盘 manifest 条数=${manifest.length}。\n` +
      '  report 陈旧，或这次跑只覆盖了 manifest 的一部分。重跑 `pnpm test:render-verification`。',
  )
}

if (navigationFailures !== 0) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S3): ${navigationFailures}/${total} 条 entry 连页面都没加载上` +
      `（check field='navigation' 失败）。\n` +
      '  这不是 drift，是**基础设施失败** —— dev server 没起来 / 中途死了 / 端口被别的 session 占了又释放。\n' +
      '  这些 entry 一个像素都没测到，本次跑不构成「测过了」的证据。先确认 5173 无残留监听再重跑。',
  )
  for (const entry of report.entries ?? []) {
    for (const check of entry.checks ?? []) {
      if (check.field !== 'navigation' || check.pass) continue
      console.error(`    ${entry.figmaName} [${entry.state ?? '-'}] ${check.actual}  (${entry.manifestId})`)
      break
    }
  }
}

if (measuredEntries !== total) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S4): 只有 ${measuredEntries}/${total} 条 entry 产出了真测量 check。\n` +
      '  其余 entry 导航成功但一条数值都没量到 —— 分母非零不等于真测了。',
  )
}
```

⚠️ S3 的逐条点名里 `check.field !== 'navigation'` 这个字面量：闸是 `.mjs`、不 import TS lib，**这是 report 数据的形态匹配，不是判据定义** —— 判据（`navigationFailures`）已经由 spec 侧算好。仅用于打印。

- [ ] **Step 4: PASS 时自印四条实测值**

把 `scripts/audit-render-drift-gate.mjs` 末尾的 `console.log(...)` 整段替换为：

```js
console.log(
  `✓ render-drift-gate PASS: A_TRUE_DRIFT_CANDIDATE=${actualA} ≤ baseline ${BASELINE_A}, ` +
    `full-tree node mismatches=${nodeMismatches} ≤ baseline ${BASELINE_NODE_MISMATCH}`,
)
console.log(
  `  INFRA-F97 测量量: S1 total=${total}>0 · S2 total===manifest(${manifest.length}) · ` +
    `S3 navigationFailures=${navigationFailures} · S4 measuredEntries=${measuredEntries}/${total}`,
)
console.log(`  report @ ${report.checkedAt ?? '?'}（本闸不判新鲜度，见头注释覆盖面边界）`)
```

- [ ] **Step 5: 阴性对照 —— 健康态必须 PASS**

Run: `pnpm audit:render-drift-gate; echo "exit=$?"`
Expected: `exit=0`，且四条实测值全部印出（S3=0、S4 两数相等）

- [ ] **Step 6: 记下健康产物的校验和（注入前）**

Run:
```bash
shasum figma-data/normalized/render-verification.report.json figma-data/render-verification-manifest.json
git status --short figma-data/
```
Expected: 记下两个 sha；`git status` 干净（或只有本轮已 commit 的内容）

- [ ] **Step 7: 注入 A —— 分母不对齐（S2）**

从磁盘 manifest 删掉最后 3 条：

```bash
node -e "const fs=require('fs');const p='figma-data/render-verification-manifest.json';const m=JSON.parse(fs.readFileSync(p,'utf8'));m.splice(-3);fs.writeFileSync(p,JSON.stringify(m,null,2)+'\n');console.log('manifest now',m.length)"
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: `exit=1`，stdout 含 `FAIL (S2)` 且点名两个条数不符
还原：`git checkout -- figma-data/render-verification-manifest.json` + `git status --short figma-data/` 确认干净

- [ ] **Step 8: 注入 B —— 字段缺失（fail closed）**

```bash
node -e "const fs=require('fs');const p='figma-data/normalized/render-verification.report.json';const r=JSON.parse(fs.readFileSync(p,'utf8'));delete r.summary.navigationFailures;fs.writeFileSync(p,JSON.stringify(r,null,2)+'\n')"
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: `exit=1`，stdout 含 `缺 summary.navigationFailures 字段`（**不是**当 0 处理后放行）
还原：`git checkout -- figma-data/normalized/render-verification.report.json` + `git status` 确认

- [ ] **Step 9: 注入 C —— `total = 0`（S1，证明 S4 不会以 `0===0` 蒙过）**

report 清空 + manifest 同步清空（使 S2 也成立，从而单独暴露 S1）：

```bash
node -e "const fs=require('fs');const rp='figma-data/normalized/render-verification.report.json';const r=JSON.parse(fs.readFileSync(rp,'utf8'));r.entries=[];r.summary.total=0;r.summary.navigationFailures=0;r.summary.measuredEntries=0;r.summary.classifications={A_TRUE_DRIFT_CANDIDATE:0,B_RESIDUAL_SCHEMA_GAP:0,C_BOUNDARY_CASE:0};r.nodeCoverage.mismatches=0;fs.writeFileSync(rp,JSON.stringify(r,null,2)+'\n');fs.writeFileSync('figma-data/render-verification-manifest.json','[]\n')"
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: `exit=1`，stdout 含 `FAIL (S1)`，且**不含** `FAIL (S2)`/`FAIL (S4)`（两者都以 0===0 成立 —— 这正是 S1 存在的理由，此处必须亲眼看到）
还原：`git checkout -- figma-data/normalized/render-verification.report.json figma-data/render-verification-manifest.json`

- [ ] **Step 10: 注入 D —— S4 单独成立（分母对、导航全成功、但没量到）**

```bash
node -e "const fs=require('fs');const p='figma-data/normalized/render-verification.report.json';const r=JSON.parse(fs.readFileSync(p,'utf8'));r.summary.measuredEntries=r.summary.total-5;fs.writeFileSync(p,JSON.stringify(r,null,2)+'\n')"
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: `exit=1`，stdout 含 `FAIL (S4)` 且**只**含 S4（S1/S2/S3 均不红 —— 证明四条各管各的、不是一坨）
还原：`git checkout -- figma-data/normalized/render-verification.report.json`

- [ ] **Step 11: 还原后逐字节核实 + 再跑一次阴性对照**

```bash
shasum figma-data/normalized/render-verification.report.json figma-data/render-verification-manifest.json
git status --short figma-data/
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: 两个 sha 与 Step 6 **逐字相同**；`git status` 干净；`exit=0`

- [ ] **Step 12: Commit**

```bash
git commit -F <msg路径> -- scripts/audit-render-drift-gate.mjs
git reset -- scripts/audit-render-drift-gate.mjs
```

---

## Task 4: F97 本体的真故障注入 —— 服务器不可用真跑一次

> spec §4.2 第一行标 **必须真跑一次**。Task 3 的四组注入都是改 JSON，**没有一组证明「真的服务器死掉时闸会红」** —— 那要走真的 playwright 跑出真的 794 条 `ERR_CONNECTION_REFUSED`。

**Files:**
- Create（**scratchpad，不进仓库**）：`<scratchpad>/playwright.f97-deadport.config.ts`
- 临时改动后还原：`figma-data/normalized/render-verification.report.json`

- [ ] **Step 1: 挑一个确实无监听的端口并确认**

```bash
lsof -nP -iTCP:5173 -sTCP:LISTEN
lsof -nP -iTCP:5199 -sTCP:LISTEN
```
Expected: 两条都无输出（5199 = 死端口；若有监听换一个再确认）

- [ ] **Step 2: 读仓库那份 config 作蓝本（不改它）**

Run: `cat playwright.render-verification.config.ts`
用途：抄 `testDir` / `testMatch` / `reporter` 等字段的真实值 —— ⛔ 别凭印象写。

- [ ] **Step 3: 在 scratchpad 写临时 config**

关键差异（其余字段照抄 Step 2 读到的真值）：
- **删掉整个 `webServer` 段**（不让 playwright 自己起 server）
- `use.baseURL` 指向 `http://localhost:5199`
- `testDir` 仍指仓库里那份 spec（用绝对路径指向仓库根的 `tests/visual-verify`）

⛔ 只许放 scratchpad。⛔ 不许改仓库里那两个 config（那就变成 spec §5 禁的「碰 `reuseExistingServer`」）。

- [ ] **Step 4: 跑它，产出真的全导航失败 report**（**后台**）

`run_in_background`：`pnpm exec playwright test --config=<scratchpad>/playwright.f97-deadport.config.ts`
⛔ 不套 `nohup … &`。
Expected: playwright 自己那句仍是 `1 passed`（这正是 F97 entry 记的现象 —— 它不构成证据）

- [ ] **Step 5: 核实 report 真的是空跑态**

```bash
node -e "const r=require('./figma-data/normalized/render-verification.report.json');console.log(JSON.stringify(r.summary,null,2))"
```
Expected: `navigationFailures` ≈ `total`（全失败）· `measuredEntries` 接近 0 · `classifications.A_TRUE_DRIFT_CANDIDATE: 0`（**A 仍是 0 —— 这就是旧闸看不见的原因，必须亲眼确认**）

- [ ] **Step 6: 跑闸 —— 必须红**

Run: `pnpm audit:render-drift-gate; echo "exit=$?"`
Expected: `exit=1`，stdout 含 `FAIL (S3)`，点名是**基础设施失败**、印出实测的失败条数，并逐条列出 `ERR_CONNECTION_REFUSED`

- [ ] **Step 7: 反证旧判据看不见它**

Run: `pnpm audit:render-drift-gate 2>&1 | grep -c 'A_TRUE_DRIFT_CANDIDATE=0'`
Expected: ≥ 0 命中即可 —— 关键是**确认这次红的原因只有 S3/S4，不是 A 超标**（即：不打 S1–S4 这四条，这次跑会 PASS）。把 stdout 原文留档进 commit body。

- [ ] **Step 8: 还原健康 report + 逐字节核实**

```bash
git checkout -- figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
shasum figma-data/normalized/render-verification.report.json
git status --short
pnpm audit:render-drift-gate; echo "exit=$?"
```
Expected: sha 与 Task 3 Step 6 逐字相同；`git status` 干净；`exit=0`

- [ ] **Step 9: 删 scratchpad 临时 config，确认仓库零残留**

```bash
rm <scratchpad>/playwright.f97-deadport.config.ts
git status --short
grep -rn "5199" --include=*.ts --include=*.mjs --include=*.json . | grep -v node_modules
```
Expected: `git status` 干净；grep 无命中（探针端口没漏进仓库）

---

## Task 5: 文档收口

**Files:**
- Modify: `docs/internal/backlog.md`（F97 entry 收口 + 新立「`checkedAt` 新鲜度」entry）
- Modify: `docs/STATUS.md`（Last updated + 当日摘要）
- Modify: `docs/internal/retrospection/design-spec-canonical-alignment-tracker.md`（轨道 C 追加一行）
- Modify: `docs/internal/STATUS-CHANGELOG.md`（逐字叙述）
- Archive: 本计划文件（`audit:plan-lifecycle` 要求 plans 有入站引用，收口后无引用 → 归档）

- [ ] **Step 1: 新 entry 的 ID 先扫**

Run: `node scripts/new-backlog.mjs INFRA`
（⚠️ 该脚本 2026-08-05 刚修好对 `INFRA-F*` 的全盲，已用 ID 0→52；仍要肉眼核一下它给的号没被占）

- [ ] **Step 2: F97 entry 改成收口态**

把 `docs/internal/backlog.md` 的 F97 entry 从「设计已定、实施未开工」改为已 ship：判据是什么（指 spec，不复述）· 四组廉价注入 + 一次真服务器故障的实测结果 · 覆盖面边界（不判新鲜度）· 新立 entry 的编号。
⚠️ **数字全取闸自印与实跑 stdout，不抄本计划、不抄 spec**。

- [ ] **Step 3: 立「`checkedAt` 新鲜度」独立 entry**

spec §5 明写它另立：它能抓「manifest 没变、单独跑闸吃一份旧的健康 report」，S2 盖不住；需要定「多旧算旧」的阈值 = 独立判据设计问题。

- [ ] **Step 4: STATUS + tracker + CHANGELOG 同步**

按 `docs/WRAP-UP.md` 走。⚠️ `audit:stale-anchors` 不扫 STATUS.md 发出的链接 —— 在 STATUS 里新引用文件路径后自己 `ls` 一遍。

- [ ] **Step 5: 归档本计划**

```bash
pnpm audit:plan-lifecycle
```
若点名本计划 0 入站引用 → 按它给的落点归档（⚠️ 抢救注释里若要提本计划，必须写**归档后**的路径，否则越认真搬迁越归档不掉）。

- [ ] **Step 6: 全闸 + commit + push**

```bash
pnpm vitest run
pnpm audit:doc-shape && pnpm audit:status-consistency && pnpm audit:plan-lifecycle && pnpm audit:stale-anchors && pnpm audit:doc-sync && pnpm audit:rule-inventory
pnpm audit:render-drift-gate
```
Expected: 全 exit 0
commit（后台）+ push，落地用 `git ls-remote` 亲验。

---

## 自审记录（writing-plans §Self-Review）

**1. spec 覆盖**：§2 四条判据 → Task 3 Step 3（逐条）· §2 fail closed → Task 3 Step 2 · §2 自印 → Task 3 Step 4 · §3 测量量落点在 spec 侧 + 字面量一处 → Task 1（常量）+ Task 2 Step 6/7（catch 块换常量 + grep 核实）· §3.1 `summarize` 签名与两个新字段定义 → Task 1 Step 3 · §3.1「刻意不接 `manifestLength`」→ Task 1 实现头注释写明 + S2 落在闸里（Task 3 Step 2）· §3.1「两个新字段同时进 summary 和 markdown」→ Task 2 Step 3/4 · §4.1 五个 case → Task 1 Step 1（写了 7 个 + `collectClassifiedChecks` 1 个）· §4.2 五行注入 → Task 3 Step 7-10（四行）+ Task 4（服务器不可用那行，单独一个 Task 因为它要真跑）+ Task 3 Step 5/11（阴性对照两次）· §4「造故障的纪律」五条 → Global Constraints + Task 3 Step 6/11（sha 前后核）+ Task 4 Step 9（探针名 grep）· §5「不做的」六条 → Global Constraints · §6 五个文件 → File Structure 表逐个有 Task。

**2. 占位符扫描**：无 TBD / 「适当处理」/「类似 Task N」。每个 code step 都有可粘贴的完整代码块；每个注入 step 都有具体命令 + 期望 exit code + 还原命令。

**3. 类型/命名一致性**：`NAVIGATION_FIELD` / `collectClassifiedChecks` / `summarize` / `RenderReportSummary` 在 Task 1 定义，Task 2 import 用同名；`total` / `navigationFailures` / `measuredEntries` 三个字段名在 Task 1（产出）、Task 2（写进 payload）、Task 3（闸读）三处逐字一致；`BASELINE_A` / `BASELINE_NODE_MISMATCH` 沿用现有名未改。

**4. 已知薄弱处（诚实登记）**：
- Task 2 Step 2/3/4 给的是**行号 + 替换内容**，而 `manifest-verifier.spec.ts` 若被并行 session 改过，行号会偏 —— 每步落刀前先 `Read` 确认那几行仍是描述的内容，不要盲改。
- Task 4 Step 3 的临时 config 字段**没有内联代码**，因为它必须照抄 Step 2 实读的仓库 config 真值（凭印象写 `testDir`/`reporter` 会跑出「0 tests」的假成功）。这是有意的「先读再写」，不是占位符。
- Task 3 Step 10 的注入 D 假设 `total > 5`。若某次 manifest 条数 ≤5，把 `-5` 改成 `-1`。
- Task 4 Step 4 那次真跑的耗时未知（健康态跑约数分钟；全导航失败因为不渲染可能更快）。走后台，别用前台等。
