# INFRA-F104 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:** 把 render-gate 的降级判据从「对散文 warning 做正则、作用在整条 entry 上」换成「结构化字段级豁免表 + 双向红」，让 188/936 entries 的结构性免疫消失。

**Architecture:** 判据 = `component` + `field` + `entryScope` 三个结构化字段，散文 `reason` 降级成注释。表是唯一豁免面，由 `tests/visual-verify/lib/render-excused-fields.ts` 加载校验、由 `classifyFailedCheck()` 消费；四个调用面因共用 `buildChecks` 自动同表。闸只读 `summarize()` 产出的数字（+ 一条纯日期的到期检查），不自己分类。

**Tech Stack:** TypeScript（Node 原生类型剥离，Node ≥22）· Vitest · Playwright · 纯 `.mjs` CLI 闸（须在 Node 20 与 22 双版本下可跑）

**真源 spec：** [`docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md`](../specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md)（判据依据；本计划不复述理由，只执行）

## Global Constraints

- **`BASELINE_A` 保持 `0`，一格不抬。** `BASELINE_NODE_MISMATCH` 同。
- **INFRA-F97 的 S1–S4 原样保留，一个字不动。**
- **闸只读数字，不自己走 `report.entries` 派生判据**（`scripts/audit-render-drift-gate.mjs` 头注释的 ⛔）。唯一例外 = S7 到期检查（纯日期比较、不含任何判据语义），必须在闸头注释里显式声明这条例外。
- **闸必须在 Node 20 与 22 下都能跑** → 闸是 `.mjs`，**不得** import `.ts` 模块（Node 20 无类型剥离）。
- **不给 React 链建闸**（[[INFRA-F100]]），只做同表。
- **不改「render-gate 进不了 `prepublishOnly`」的格局**（Gitea runner 装不了 chromium），收尾也不得宣称改了。
- **不修 Slider / Message / Button 的 code**，它们进表带闹钟。
- **不动 `.vue` / `.css`** —— 本条全程不应触发视觉门；若发现必须动，**停下要 `VISUAL_COMMIT_APPROVED=1`，AI 不自设**。
- **commit 纪律**：`git commit -F <msgfile> -- <路径逐条内联>`，提交后紧跟 `git reset -- <路径>`。本仓库多 session 并行、共享 git index。
- **字段闭集真源 = `buildChecks()` 的产出**，不得在别处硬编码第二份清单。
- 每次改分类逻辑后，**必须重跑 `pnpm test:render-verification`（约 1.5 min）才能跑闸** —— classification 是在 spec 侧算完写进 report 的，不重跑就是在读旧分类。跑前先 `lsof -nP -iTCP:5173` 确认无并发 vite server。

---

## File Structure

| 文件 | 责任 |
|---|---|
| `figma-data/audit-allowlist/render-excused-fields.json` | **新建**。豁免表数据，37 行。唯一豁免面 |
| `tests/visual-verify/lib/render-excused-fields.ts` | **新建**。加载 + schema 校验（失败即 throw）+ glob 匹配。不含任何组件知识 |
| `tests/visual-verify/lib/drift-compare-core.ts` | **改**。`classifyFailedCheck()` 换查表；`buildChecks()` 的 `rootRadius` 改有效半径 |
| `tests/visual-verify/lib/render-report-summary.ts` | **改**。`summarize()` 增 `excuse` 统计 |
| `scripts/audit-render-drift-gate.mjs` | **改**。增 S5/S6/S7 + 头注释 |
| `tests/render-excused-fields.test.ts` | **新建**。loader 的 schema / 匹配 / fail-closed 单测 |
| `tests/render-report-summary.test.ts` | **改**。增 `excuse` 统计单测 |
| `tests/drift-compare-classify.test.ts` | **新建**。分类器双向红 + 有效半径单测 |
| `src/design-system/translation/divergences-decisions.json` | **改**。桶 4 的 code-first divergence 登记 |

---

## Task 1: 豁免表数据 + loader 模块

**Files:**
- Create: `figma-data/audit-allowlist/render-excused-fields.json`
- Create: `tests/visual-verify/lib/render-excused-fields.ts`
- Test: `tests/render-excused-fields.test.ts`

**Interfaces:**
- Consumes: 无（本任务是叶子）
- Produces:
  ```ts
  export type ExcuseRow = {
    component: string
    field: string
    entryScope: string
    reason: string
    fixDirection: 'extract-pipeline' | 'harness-verifier' | 'code-fix-deferred' | 'code-first-divergence' | 'figma-schema-gap'
    divergenceRef?: string
    addedAt: string
    reviewBy: string
  }
  export const EXCUSE_TABLE_PATH: string
  export const FIX_DIRECTIONS: readonly string[]
  export function validateExcuseRows(raw: unknown, knownFields: readonly string[]): ExcuseRow[]  // 违规即 throw
  export function loadExcusedFields(knownFields: readonly string[]): ExcuseRow[]
  export function globToRegExp(glob: string): RegExp
  export function isExcused(rows: readonly ExcuseRow[], component: string, manifestId: string, field: string): boolean
  ```

- [ ] **Step 1: 写失败的单测**

Create `tests/render-excused-fields.test.ts`:

```ts
import { describe, it, expect } from 'vitest'
import {
  globToRegExp,
  isExcused,
  validateExcuseRows,
  type ExcuseRow,
} from './visual-verify/lib/render-excused-fields'

const KNOWN = ['rootWidth', 'rootHeight', 'rootFillHex', 'rootPadding.left'] as const

function row(over: Partial<ExcuseRow> = {}): ExcuseRow {
  return {
    component: 'Fixture',
    field: 'rootFillHex',
    entryScope: '*',
    reason: 'fixture reason',
    fixDirection: 'harness-verifier',
    addedAt: '2026-08-07',
    reviewBy: '2027-02-03',
    ...over,
  } as ExcuseRow
}

describe('globToRegExp', () => {
  it('* 匹配任意跨度，其余字符字面匹配', () => {
    expect(globToRegExp('*').test('anything')).toBe(true)
    expect(globToRegExp('a--*--b').test('a--xy--b')).toBe(true)
    expect(globToRegExp('a--*--b').test('a--b')).toBe(false)
  })

  it('正则元字符被转义，不当模式解释', () => {
    // '.' 必须只匹配真的点，不匹配任意字符
    expect(globToRegExp('rootPadding.left').test('rootPaddingXleft')).toBe(false)
    expect(globToRegExp('rootPadding.left').test('rootPadding.left')).toBe(true)
  })

  it('锚定首尾 —— 子串不算命中', () => {
    expect(globToRegExp('abc').test('xabcx')).toBe(false)
  })
})

describe('validateExcuseRows — fail closed', () => {
  it('合法表通过并原样返回', () => {
    expect(validateExcuseRows([row()], KNOWN)).toHaveLength(1)
  })

  it('顶层不是数组 → throw', () => {
    expect(() => validateExcuseRows({}, KNOWN)).toThrow(/数组/)
  })

  it('field 不在闭集 → throw（拼错成另一个真字段名同样被这条挡住的前提是闭集完整）', () => {
    expect(() => validateExcuseRows([row({ field: 'rootPaddingLeft' })], KNOWN)).toThrow(/字段闭集/)
  })

  it('缺 reason → throw', () => {
    expect(() => validateExcuseRows([row({ reason: '' })], KNOWN)).toThrow(/reason/)
  })

  it('fixDirection 不在枚举 → throw', () => {
    expect(() => validateExcuseRows([row({ fixDirection: 'whatever' as never })], KNOWN)).toThrow(/fixDirection/)
  })

  it('addedAt 非 ISO 日期 → throw', () => {
    expect(() => validateExcuseRows([row({ addedAt: '2026/08/07' })], KNOWN)).toThrow(/addedAt/)
  })

  it('addedAt 在未来 → throw', () => {
    expect(() => validateExcuseRows([row({ addedAt: '2099-01-01' })], KNOWN)).toThrow(/未来/)
  })

  it('reviewBy 非 ISO 日期 → throw', () => {
    expect(() => validateExcuseRows([row({ reviewBy: 'someday' })], KNOWN)).toThrow(/reviewBy/)
  })

  it('reviewBy 早于 addedAt → throw', () => {
    expect(() => validateExcuseRows([row({ reviewBy: '2026-01-01' })], KNOWN)).toThrow(/reviewBy/)
  })

  it('同一 (component, field, entryScope) 重复 → throw（重复行会让 shrink-only 判据失真）', () => {
    expect(() => validateExcuseRows([row(), row()], KNOWN)).toThrow(/重复/)
  })
})

describe('isExcused', () => {
  const rows = [
    row({ component: 'switch', field: 'rootFillHex', entryScope: 'switch--*--enable-no--*' }),
    row({ component: 'Chart', field: 'rootWidth', entryScope: '*' }),
  ]

  it('三个字段全中才豁免', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootFillHex')).toBe(true)
  })

  it('组件不同 → 不豁免', () => {
    expect(isExcused(rows, 'Slider', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootFillHex')).toBe(false)
  })

  it('字段不同 → 不豁免（entry 级免疫已消失）', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-no--loading-no--x', 'rootWidth')).toBe(false)
  })

  it('manifestId 落在 entryScope 外 → 不豁免', () => {
    expect(isExcused(rows, 'switch', 'switch--darktheme-on--status-on--enable-yes--loading-no--x', 'rootFillHex')).toBe(false)
  })

  it('空表 → 一律不豁免（最严，不是通配）', () => {
    expect(isExcused([], 'Chart', 'chart--type-bar--x', 'rootWidth')).toBe(false)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/render-excused-fields.test.ts`
Expected: FAIL —— `Cannot find module './visual-verify/lib/render-excused-fields'`

- [ ] **Step 3: 写 loader 实现**

Create `tests/visual-verify/lib/render-excused-fields.ts`:

```ts
// INFRA-F104: render-verification 豁免表的加载 + 校验 + 匹配。
//
// 判据 = component + field + entryScope 三个**结构化**字段。`reason` 是给人读的注释，
// **不参与判据** —— 这正是本模块存在的理由：此前降级判据是对 entry 的散文 warning 做
// 正则（drift-compare-core.ts:413），一句 `harness limitation across all checks` 就能让
// 该 entry 的任何字段免疫，实测 188/936 entries = 20.1% 结构上不可能变红。
//
// fail closed 的四条（全部有单测钉住）：
//   · 文件缺失 / JSON 坏 / 顶层不是数组 → throw（不是「没有豁免所以放行」）
//   · field 不在 buildChecks 派生的闭集 → throw（拼错成另一个真字段名 = 静默扩大豁免面）
//   · addedAt / reviewBy 非 ISO、addedAt 在未来、reviewBy 早于 addedAt → throw
//   · 同一 (component, field, entryScope) 重复 → throw（重复行让 shrink-only 判据失真）
// entry 匹配不到任何行 → 空列表（最严），**绝不视为通配**。
//
// ⛔ 字段闭集由调用方从 buildChecks() 的产出传入，**不在本文件硬编码第二份清单**。
// 判据真源：docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md
import fs from 'node:fs'
import path from 'node:path'

export const EXCUSE_TABLE_PATH = 'figma-data/audit-allowlist/render-excused-fields.json'

export const FIX_DIRECTIONS = [
  'extract-pipeline',
  'harness-verifier',
  'code-fix-deferred',
  'code-first-divergence',
  'figma-schema-gap',
] as const

export type FixDirection = (typeof FIX_DIRECTIONS)[number]

export type ExcuseRow = {
  component: string
  field: string
  entryScope: string
  reason: string
  fixDirection: FixDirection
  divergenceRef?: string
  addedAt: string
  reviewBy: string
}

const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/

function isRealIsoDate(value: unknown): value is string {
  if (typeof value !== 'string' || !ISO_DATE.test(value)) return false
  const parsed = new Date(`${value}T00:00:00Z`)
  return !Number.isNaN(parsed.getTime()) && parsed.toISOString().slice(0, 10) === value
}

/** glob → 锚定首尾的 RegExp。`*` 是唯一通配符，其余字符字面匹配（正则元字符全部转义）。 */
export function globToRegExp(glob: string): RegExp {
  const escaped = glob
    .split('*')
    .map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
    .join('.*')
  return new RegExp(`^${escaped}$`)
}

export function validateExcuseRows(raw: unknown, knownFields: readonly string[]): ExcuseRow[] {
  if (!Array.isArray(raw)) {
    throw new Error(`[render-excused-fields] 顶层必须是数组，实际 ${Object.prototype.toString.call(raw)}`)
  }
  const today = new Date().toISOString().slice(0, 10)
  const seen = new Set<string>()
  return raw.map((row, i) => {
    const at = `[render-excused-fields] 第 ${i} 行`
    if (typeof row !== 'object' || row === null) throw new Error(`${at}不是对象`)
    const r = row as Record<string, unknown>
    for (const key of ['component', 'field', 'entryScope', 'reason', 'fixDirection', 'addedAt', 'reviewBy']) {
      if (typeof r[key] !== 'string' || (r[key] as string).trim() === '') {
        throw new Error(`${at}缺 ${key} 或不是非空字符串`)
      }
    }
    if (!knownFields.includes(r.field as string)) {
      throw new Error(
        `${at} field="${r.field}" 不在 buildChecks 的字段闭集里（${knownFields.join(' / ')}）。` +
          ' 拼错成另一个真字段名会静默扩大豁免面 —— fail closed。',
      )
    }
    if (!FIX_DIRECTIONS.includes(r.fixDirection as FixDirection)) {
      throw new Error(`${at} fixDirection="${r.fixDirection}" 不在枚举（${FIX_DIRECTIONS.join(' / ')}）`)
    }
    if (!isRealIsoDate(r.addedAt)) throw new Error(`${at} addedAt="${r.addedAt}" 不是 ISO 日期（YYYY-MM-DD）`)
    if (!isRealIsoDate(r.reviewBy)) throw new Error(`${at} reviewBy="${r.reviewBy}" 不是 ISO 日期（YYYY-MM-DD）`)
    if ((r.addedAt as string) > today) throw new Error(`${at} addedAt="${r.addedAt}" 在未来`)
    if ((r.reviewBy as string) <= (r.addedAt as string)) {
      throw new Error(`${at} reviewBy="${r.reviewBy}" 必须晚于 addedAt="${r.addedAt}"`)
    }
    if (r.divergenceRef !== undefined && typeof r.divergenceRef !== 'string') {
      throw new Error(`${at} divergenceRef 存在时必须是字符串`)
    }
    const key = `${r.component}::${r.field}::${r.entryScope}`
    if (seen.has(key)) throw new Error(`${at}与前面的行重复：(${r.component}, ${r.field}, ${r.entryScope})`)
    seen.add(key)
    return r as unknown as ExcuseRow
  })
}

let cache: ExcuseRow[] | null = null

export function loadExcusedFields(knownFields: readonly string[]): ExcuseRow[] {
  if (cache) return cache
  const file = path.resolve(EXCUSE_TABLE_PATH)
  if (!fs.existsSync(file)) {
    throw new Error(
      `[render-excused-fields] 找不到 ${EXCUSE_TABLE_PATH} —— fail closed。` +
        ' 表缺失不等于「没有豁免所以放行」，也不等于「读不到所以跳过」。',
    )
  }
  let parsed: unknown
  try {
    parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
  } catch (error) {
    throw new Error(`[render-excused-fields] ${EXCUSE_TABLE_PATH} JSON 解析失败：${(error as Error).message}`)
  }
  cache = validateExcuseRows(parsed, knownFields)
  return cache
}

export function isExcused(
  rows: readonly ExcuseRow[],
  component: string,
  manifestId: string,
  field: string,
): boolean {
  return rows.some(
    (row) => row.component === component && row.field === field && globToRegExp(row.entryScope).test(manifestId),
  )
}
```

- [ ] **Step 4: 跑测试确认全过**

Run: `pnpm vitest run tests/render-excused-fields.test.ts`
Expected: PASS（21 个用例）

- [ ] **Step 5: 写豁免表数据文件**

Create `figma-data/audit-allowlist/render-excused-fields.json`。**37 行，逐字如下**（`entryScope` 已机械验证过命中面，见 spec §7）。

⚠️ **本文件是纯数组，不带 `_comment` 头** —— 兄弟文件 `variables-staleness-ack.json` 是单个对象所以能带 `_comment` 键，数组带不了。表的用途说明、fail-closed 语义、判据边界全部写在 loader（`render-excused-fields.ts`）的头注释里，**那里是唯一说明处，别在 JSON 里塞一个假的第 0 行当注释**（它会被当成一条真行去校验）。

```json
[
  { "component": "switch", "field": "rootPadding.top", "entryScope": "*", "reason": "Switch rootPadding 2px is thumb-inset positioning (code uses padding + transform; Figma positions thumb absolutely with root padding=0).", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "switch", "field": "rootPadding.right", "entryScope": "*", "reason": "同 rootPadding.top：thumb-inset 定位差异，code 用 padding + transform，Figma 用绝对定位 + root padding=0。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "switch", "field": "rootPadding.bottom", "entryScope": "*", "reason": "同 rootPadding.top：thumb-inset 定位差异，code 用 padding + transform，Figma 用绝对定位 + root padding=0。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "switch", "field": "rootPadding.left", "entryScope": "*", "reason": "同 rootPadding.top：thumb-inset 定位差异，code 用 padding + transform，Figma 用绝对定位 + root padding=0。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "switch", "field": "rootFillHex", "entryScope": "switch--*--enable-no--*", "reason": "enable=off 变体 Figma 未提取到 root fill（expected null），verifier 读到的是原生 <button> 的 UA 默认背景。原警告：harness limitation — null text/fill expected vs actual UA defaults on native <button>.", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "switch", "field": "rootFillHex", "entryScope": "switch--*--status-live--enable-yes--loading-no--*", "reason": "status=live 且可用态：同一 UA 默认背景问题，但成因与 enable=off 那组不同（不是禁用态），故单列一行。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Step/Item", "field": "textFillHex", "entryScope": "*", "reason": "StepItem completed state renders <Icon name=\"selected\"/> not .step-num text — textTargetSelector misses (falls back to root #000). harness limitation: verifier needs state-conditional textTargetSelector. ⚠️ 本行放大 +4（18 条中今天 14 条失败）：另 4 条是否命中取决于该变体有没有渲染出 icon，属同一缺陷。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootPadding.top", "entryScope": "*", "reason": "Tooltip Figma extract drops root autolayout padding/gap/fill (similar to PopupBox EXTRACT pipeline gap). Code padding 4/8/4/8 是真值，Figma expected null 反映的是 extract 缺数据。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootPadding.right", "entryScope": "*", "reason": "同 rootPadding.top：Tooltip extract 丢了 root autolayout padding/gap/fill。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootPadding.bottom", "entryScope": "*", "reason": "同 rootPadding.top：Tooltip extract 丢了 root autolayout padding/gap/fill。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootPadding.left", "entryScope": "*", "reason": "同 rootPadding.top：Tooltip extract 丢了 root autolayout padding/gap/fill。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootFillHex", "entryScope": "*", "reason": "同族 extract 缺口：code bg #353535 是真值，Figma expected null 是 extract 没抓到，不是「Figma 说无填充」。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootBorderHex", "entryScope": "*", "reason": "同族 extract 缺口：expected null = 没抓到，不是「Figma 说无边框」。原 warning 只列了 padding/gap/fill，字段列写漏，本行是补齐不是新豁免。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootGap", "entryScope": "*--pointing-*-down--*", "reason": "同族 extract 缺口：expected -6 是缺数据的产物。只命中 pointing=*-down 的 6 条。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootWidth", "entryScope": "*--pointing-*-up--*", "reason": "同族 extract 缺口：expected 是文字 hug 测量值。只命中 pointing=*-up 的 6 条。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tooltips", "field": "rootHeight", "entryScope": "*--pointing-*-up--*", "reason": "同族 extract 缺口：expected 是分数（文字 hug 测量）。只命中 pointing=*-up 的 6 条。", "fixDirection": "extract-pipeline", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Chart", "field": "rootWidth", "entryScope": "*", "reason": "Chart renders into an inline SVG via ECharts — verifier reads only root wrapper computed style; pixel content not introspectable.", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Chart", "field": "rootHeight", "entryScope": "*", "reason": "同 rootWidth：ECharts 内联 SVG 的像素内容不可内省。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Chart", "field": "rootGap", "entryScope": "*", "reason": "同 rootWidth：ECharts 内联 SVG 的像素内容不可内省。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Chart", "field": "textFillHex", "entryScope": "*", "reason": "同 rootWidth：文字在 SVG 内部，verifier 读的是 wrapper。⚠️ 本行放大 +1（12 条中今天 11 条失败）：donut/light 那条今天 pass 是偶然，限制对全部 chart type 通用。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Breadcrumb/Item", "field": "textFillHex", "entryScope": "*", "reason": "BreadcrumbItem label uses <slot/> — harness limitation: empty slot content makes label dimensions 0 → not visible to verifier; textFillHex falls back to root LI and reads #000000. Real text colour resolves correctly at runtime.", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Message", "field": "textFillHex", "entryScope": "*--theme-light", "reason": "PromptMessage `--prompt-*-bg` / `--prompt-*-text` tokens 未 theme-varied，全主题共用当前的 light 值。⚠️ 这是**自认的真 code 债**（原 warning: pending real fix in followup sprint — add dark-theme override block or split token names），不是限制，故走 90 天短闹钟。", "fixDirection": "code-fix-deferred", "addedAt": "2026-08-07", "reviewBy": "2026-11-05" },
  { "component": "Message", "field": "rootFillHex", "entryScope": "message--status-success--*--theme-dark", "reason": "同上的 dark 面：dark 主题渲出 light 值（expected #0b2b13 / actual #f1fbf3）。自认的真 code 债，90 天闹钟。", "fixDirection": "code-fix-deferred", "addedAt": "2026-08-07", "reviewBy": "2026-11-05" },
  { "component": "Drop down List/Item", "field": "rootFillHex", "entryScope": "*-disabled-yes-hover-yes--*", "reason": "DropDownListSelect disabled+hover 是 Figma 预览态；code intentionally suppresses for runtime UX correctness。已登记 divergence。", "fixDirection": "figma-schema-gap", "divergenceRef": "translation/divergences.md#dropdown-disabled-hover-preview-state", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Drop down List/Item", "field": "rootFillHex", "entryScope": "*-type-multi-is-parent-yes-checked-yes-active-yes-disabled-no-hover-no--*", "reason": "Figma Is Parent=Yes 没有对应的 DropDownListSelect runtime prop，视觉差属 boundary/schema gap。与 disabled+hover 那组是两个成因，故单列一行。", "fixDirection": "figma-schema-gap", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Drop down List/Item", "field": "textFillHex", "entryScope": "*-radio-is-parent-yes-checked-yes-active-no-disabled-yes-hover-no--theme-light", "reason": "同 Is Parent=Yes 无 runtime prop 的 schema gap，只命中 light 主题那一条。", "fixDirection": "figma-schema-gap", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tab List", "field": "rootBorderHex", "entryScope": "*--fill-line--*", "reason": "Tab List rootBorder 是 verifier resolution 限制：Figma extract 给的是聚合 root 颜色，code 用的是 per-side border（active 指示线）。含 tab-shell 与 tab-list 两种入口，Tab 与 TabList 实现同一个 Figma 节点、必须视觉一致。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tab List", "field": "textFillHex", "entryScope": "*--fill-filled--*--theme-light", "reason": "Filled 型的 textFillHex 同属 verifier resolution 限制：Figma 聚合色 vs code 的 per-tab-state 文字色。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Tab/Item", "field": "rootBorderHex", "entryScope": "*--state-active--color-white--fill-line--*", "reason": "TabItem Line type rootBorderHex 与 TabList 同族：Figma extract 给聚合 root 颜色，code 只画 bottom border 作 active 指示。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Slider", "field": "rootWidth", "entryScope": "*", "reason": "Slider rootWidth 192px 是 <input type=range> 的 UA 默认宽 vs Figma 240px。⚠️ 这是**自认的真 code 债**（原 warning 逐字：real width fix needs explicit `width: 240px` on .slider__input），差 20% 超出 C_BOUNDARY 的 5%，故走 90 天短闹钟。", "fixDirection": "code-fix-deferred", "addedAt": "2026-08-07", "reviewBy": "2026-11-05" },
  { "component": "Slider", "field": "rootGap", "entryScope": "*", "reason": "gap 量的是 .slider wrapper 而非 .slider__input，与上面同一条 warning 覆盖。随 width 修复一并复核。", "fixDirection": "code-fix-deferred", "addedAt": "2026-08-07", "reviewBy": "2026-11-05" },
  { "component": "Slider", "field": "textFillHex", "entryScope": "slider--theme-light--*", "reason": ".slider__value 用 theme-aware 的 --text-2，Figma 用静态 --color-grey-5，只在 light 侧显现。随 Slider 保真修复一并复核。", "fixDirection": "code-fix-deferred", "addedAt": "2026-08-07", "reviewBy": "2026-11-05" },
  { "component": "Button/url link", "field": "rootPadding.right", "entryScope": "*", "reason": "owner 2026-08-07 裁定「URL link 不由 canonical Button 承载」，按合法 divergence 第 6 类（用户显式 code-first）登记。Figma URL-link 变体横向 padding 4，canonical Button 渲成 16。", "fixDirection": "code-first-divergence", "divergenceRef": "button-url-link-not-carried-by-canonical-button-2026-08-07", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Button/url link", "field": "rootPadding.left", "entryScope": "*", "reason": "同 rootPadding.right：owner 2026-08-07 code-first 裁定。", "fixDirection": "code-first-divergence", "divergenceRef": "button-url-link-not-carried-by-canonical-button-2026-08-07", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Button/url link", "field": "textFillHex", "entryScope": "*", "reason": "Figma URL link 变体有自己的 typography/underline，canonical Button 不实现 —— 与 padding / hover 底色属同一裁定的三个面。", "fixDirection": "code-first-divergence", "divergenceRef": "button-url-link-not-carried-by-canonical-button-2026-08-07", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Button/url link", "field": "rootFillHex", "entryScope": "*--status-hover--*", "reason": "Figma URL-link hover 无底色，canonical Button 给了 #434343 灰底。同属 owner 2026-08-07 code-first 裁定。", "fixDirection": "code-first-divergence", "divergenceRef": "button-url-link-not-carried-by-canonical-button-2026-08-07", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" },
  { "component": "Table", "field": "rootBorderHex", "entryScope": "*--type-tbody--*", "reason": "border-collapse 把表头行的 border-bottom(--line-deep) 解析到首行 body 单元格的上边，getComputedStyle 因此报出一条不属于该单元格的 1px 上边框；Figma tbody 单元格本身无 stroke。本行由 drift-compare-core.ts 原 402-409 行的字段特定降级迁入 —— 那条降级今天已被 entry 级规则架空（实证见 spec §2a）。", "fixDirection": "harness-verifier", "addedAt": "2026-08-07", "reviewBy": "2027-02-03" }
]
```

- [ ] **Step 6: 机械核对表的命中面**

把下面这段临时脚本写到 worktree 根的 `_tmp-verify-globs.mjs`（跑完即删，**不进 git**），跑一遍：

```js
import fs from 'node:fs'
const rows = JSON.parse(fs.readFileSync('figma-data/audit-allowlist/render-excused-fields.json', 'utf8'))
const r = JSON.parse(fs.readFileSync('figma-data/normalized/render-verification.report.json', 'utf8'))
const toRe = (g) => new RegExp('^' + g.split('*').map((s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')).join('.*') + '$')
let covered = 0
const failing = new Set()
for (const e of r.entries) for (const c of e.checks || []) if (c.status === 'fail') failing.add(e.manifestId + '::' + c.field)
for (const [i, row] of rows.entries()) {
  const re = toRe(row.entryScope)
  const hit = r.entries.filter((e) => e.figmaName === row.component && re.test(e.manifestId))
  const hitFail = hit.filter((e) => failing.has(e.manifestId + '::' + row.field))
  covered += hitFail.length
  console.log(`${String(i).padStart(2)} ${row.component} | ${row.field} | 命中=${hit.length} 其中失败=${hitFail.length} 放大+${hit.length - hitFail.length}`)
}
const uncovered = [...failing].filter(([]) => true).filter((k) => {
  const [id, field] = k.split('::')
  const e = r.entries.find((x) => x.manifestId === id)
  return !rows.some((row) => row.component === e.figmaName && row.field === field && toRe(row.entryScope).test(id))
})
console.log('\n行数 =', rows.length, '| 被覆盖的失败 check =', covered, '| 未被覆盖 =', uncovered.length)
for (const k of uncovered) console.log('   未覆盖:', k.replace('::', ' | '))
```

Run: `node _tmp-verify-globs.mjs`
Expected（逐条核对，与 spec §7 一致）：
- `行数 = 37`
- `被覆盖的失败 check = 300`
- `未被覆盖 = 4` —— 且这 4 条**必须全部是 switch 的 `rootRadius`**（它们由 Task 2 的有效半径判据转成真 PASS，刻意不进表）
- 放大只有两处：`Step/Item | textFillHex` 放大 +4、`Chart | textFillHex` 放大 +1。**出现第三处放大即停下重收 glob。**

跑完 `rm -f _tmp-verify-globs.mjs`。

- [ ] **Step 7: 跑全量单测确认没碰坏别的**

Run: `pnpm vitest run`
Expected: PASS（基线 998 passed / 10 skipped，加上本任务新增的 21 条）

- [ ] **Step 8: Commit**

```bash
git add -- figma-data/audit-allowlist/render-excused-fields.json tests/visual-verify/lib/render-excused-fields.ts tests/render-excused-fields.test.ts
git commit -F <msgfile> -- figma-data/audit-allowlist/render-excused-fields.json tests/visual-verify/lib/render-excused-fields.ts tests/render-excused-fields.test.ts
git reset -- figma-data/audit-allowlist/render-excused-fields.json tests/visual-verify/lib/render-excused-fields.ts tests/render-excused-fields.test.ts
```
msgfile 内容首行：`feat(render-gate): 加豁免表 + fail-closed loader（INFRA-F104 Task 1，尚无消费者）`

---

## Task 2: 分类器换查表 + rootRadius 有效半径

**Files:**
- Modify: `tests/visual-verify/lib/drift-compare-core.ts:395-421`（`classifyFailedCheck`）与 `:470`（`rootRadius` 的 push）
- Test: `tests/drift-compare-classify.test.ts`（新建）

**Interfaces:**
- Consumes: Task 1 的 `isExcused` / `loadExcusedFields` / `ExcuseRow`
- Produces:
  ```ts
  export const CHECK_FIELDS: readonly string[]        // buildChecks 派生的字段闭集，唯一定义
  export function effectiveRadius(radius: number | null, w: number | null, h: number | null): number | null
  export function classifyFailedCheck(entry, check, excuses?: readonly ExcuseRow[]): 'A_TRUE_DRIFT_CANDIDATE' | 'B_RESIDUAL_SCHEMA_GAP' | 'C_BOUNDARY_CASE' | undefined
  ```

- [ ] **Step 1: 写失败的单测**

Create `tests/drift-compare-classify.test.ts`:

```ts
import { describe, it, expect } from 'vitest'
import {
  CHECK_FIELDS,
  classifyFailedCheck,
  effectiveRadius,
} from './visual-verify/lib/drift-compare-core'
import type { ExcuseRow } from './visual-verify/lib/render-excused-fields'

const excuses: ExcuseRow[] = [
  {
    component: 'Chart',
    field: 'rootWidth',
    entryScope: '*',
    reason: 'fixture',
    fixDirection: 'harness-verifier',
    addedAt: '2026-08-07',
    reviewBy: '2027-02-03',
  },
]

function entry(over: Record<string, unknown> = {}) {
  return {
    manifestId: 'chart--type-bar--x',
    figmaNodeId: '1:1',
    figmaName: 'Chart',
    figmaVariantName: 'Chart/bar',
    codeComponent: 'Chart',
    codeProps: {},
    renderRoute: '/x',
    _warnings: ['harness limitation across all token / colour / sizing checks.'],
    ...over,
  } as never
}

const fail = (field: string, expected: unknown, actual: unknown) =>
  ({ field, expected, actual, pass: false, status: 'fail' }) as never

describe('classifyFailedCheck — 散文 warning 不再是判据', () => {
  it('表里有该 (组件,字段) → B', () => {
    expect(classifyFailedCheck(entry(), fail('rootWidth', 240, 192), excuses)).toBe('B_RESIDUAL_SCHEMA_GAP')
  })

  it('同一 entry 的**别的字段** → A（entry 级免疫已消失，本条是本任务的核心回归）', () => {
    expect(classifyFailedCheck(entry(), fail('rootFillHex', '#0b2b13', '#ff00ff'), excuses)).toBe(
      'A_TRUE_DRIFT_CANDIDATE',
    )
  })

  it('带足六个旧短语的 warning 也不再降级', () => {
    const noisy = entry({
      _warnings: [
        'pseudo-state runtime prop runtime type runtime UX harness limitation verifier resolution',
      ],
    })
    expect(classifyFailedCheck(noisy, fail('rootFillHex', '#000000', '#ff00ff'), excuses)).toBe(
      'A_TRUE_DRIFT_CANDIDATE',
    )
  })

  it('空表 → 一律 A', () => {
    expect(classifyFailedCheck(entry(), fail('rootWidth', 240, 192), [])).toBe('A_TRUE_DRIFT_CANDIDATE')
  })

  it('manifestId 落在 entryScope 外 → A', () => {
    const scoped: ExcuseRow[] = [{ ...excuses[0], entryScope: 'chart--type-donut--*' }]
    expect(classifyFailedCheck(entry(), fail('rootWidth', 240, 192), scoped)).toBe('A_TRUE_DRIFT_CANDIDATE')
  })

  it('renderTarget / navigation 仍是 B（基础设施信号，不经表）', () => {
    expect(classifyFailedCheck(entry(), fail('navigation', 'route loaded', 'ERR'), [])).toBe(
      'B_RESIDUAL_SCHEMA_GAP',
    )
    expect(classifyFailedCheck(entry(), fail('renderTarget', 'visible', 'missing: .x'), [])).toBe(
      'B_RESIDUAL_SCHEMA_GAP',
    )
  })

  it('≤5% 的宽高差仍归 C（数值容差判据保留）', () => {
    expect(classifyFailedCheck(entry(), fail('rootWidth', 100, 102), [])).toBe('C_BOUNDARY_CASE')
  })

  it('pass 的 check 不分类', () => {
    expect(
      classifyFailedCheck(entry(), { field: 'rootWidth', expected: 1, actual: 1, pass: true, status: 'pass' } as never, []),
    ).toBeUndefined()
  })
})

describe('effectiveRadius — CSS Backgrounds §5.5 corner clamp', () => {
  it('40x20 上 100 与 20 都 clamp 到 10（浏览器实测已证像素等价）', () => {
    expect(effectiveRadius(100, 40, 20)).toBe(10)
    expect(effectiveRadius(20, 40, 20)).toBe(10)
  })

  it('未超出时原样返回', () => {
    expect(effectiveRadius(4, 40, 20)).toBe(4)
  })

  it('盒子拿不到时退回字面值（fail closed —— 不因为量不到就放行）', () => {
    expect(effectiveRadius(100, null, 20)).toBe(100)
    expect(effectiveRadius(100, 40, null)).toBe(100)
  })

  it('radius 为 null 时保持 null', () => {
    expect(effectiveRadius(null, 40, 20)).toBeNull()
  })
})

describe('CHECK_FIELDS 是字段闭集的唯一定义', () => {
  it('含 buildChecks 会产出的全部字段（含 pickup 漏列的六个）', () => {
    for (const f of [
      'rootWidth', 'rootHeight',
      'rootPadding.top', 'rootPadding.right', 'rootPadding.bottom', 'rootPadding.left',
      'rootRadius', 'rootGap', 'rootOpacity', 'rootBorderWidth',
      'rootFillHex', 'rootBorderHex', 'textFillHex',
      'textInset.left', 'textInset.right',
      'renderTarget', 'navigation',
    ]) {
      expect(CHECK_FIELDS, f).toContain(f)
    }
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/drift-compare-classify.test.ts`
Expected: FAIL —— `CHECK_FIELDS` / `effectiveRadius` 未导出

- [ ] **Step 3: 改 `drift-compare-core.ts`**

在 import 段加：

```ts
import { isExcused, loadExcusedFields, type ExcuseRow } from './render-excused-fields'
```

在 `classifyFailedCheck` 之前加：

```ts
/**
 * buildChecks() 会产出的**全部** check field —— 字段闭集的唯一定义。
 * 豁免表的 `field` 必须 ∈ 本集合（拼错成另一个真字段名 = 静默扩大豁免面）。
 * ⛔ 改 buildChecks 加字段时必须同步本表，否则新字段永远无法进豁免表。
 */
export const CHECK_FIELDS = [
  'renderTarget',
  'navigation',
  'rootWidth',
  'rootHeight',
  'rootPadding.top',
  'rootPadding.right',
  'rootPadding.bottom',
  'rootPadding.left',
  'rootRadius',
  'rootGap',
  'rootOpacity',
  'rootBorderWidth',
  'rootFillHex',
  'rootBorderHex',
  'textFillHex',
  'textInset.left',
  'textInset.right',
] as const

/**
 * CSS Backgrounds §5.5 corner overlap：盒子放不下时四角半径被等比缩到 min(w,h)/2。
 * 2026-08-07 chromium 实测（含阴阳对照）：40×20 上 radius 20 / 100 / 10 三者逐像素相同，4px 不同。
 * ⚠️ getComputedStyle().borderRadius 返回的是**声明值**不是 clamp 后的值，所以必须在这里自己算。
 * 盒子拿不到（suppressRootSize / mode-skip / 非数字）→ 退回字面值，fail closed。
 */
export function effectiveRadius(radius: number | null, w: number | null, h: number | null): number | null {
  if (radius === null || typeof radius !== 'number') return radius
  if (typeof w !== 'number' || typeof h !== 'number') return radius
  return Math.min(radius, Math.min(w, h) / 2)
}
```

把 `classifyFailedCheck` 整体替换为：

```ts
export function classifyFailedCheck(
  entry: ManifestEntry,
  check: CheckResult,
  excuses: readonly ExcuseRow[] = loadExcusedFields(CHECK_FIELDS),
) {
  if (check.pass) return undefined
  // 基础设施信号，不经豁免表：这两个 field 不描述视觉，只描述「有没有测成」。
  if (check.field === 'renderTarget' || check.field === 'navigation') return 'B_RESIDUAL_SCHEMA_GAP' as const
  // INFRA-F104：判据 = 结构化的 (component, field, entryScope) 三元组。
  // 此前是对 entry 的散文 warning 做正则（六个短语），命中即该 entry 的**任何字段**免疫
  //   —— 实测 188/936 entries = 20.1% 结构上不可能变红。散文现已降级成表里的 `reason` 注释。
  // 同时删掉的还有两条散文锚定分支：placeholder 那条（今日命中 0，死分支）与 border-collapse
  // 那条（做成 field-specific 是对的，但同一句 warning 含 "verifier resolution" 会被 entry 级
  //   规则兜住 → 意图早已失效，实证见 spec §2a）。后者已迁进表。
  if (isExcused(excuses, entry.figmaName, entry.manifestId, check.field)) {
    return 'B_RESIDUAL_SCHEMA_GAP' as const
  }
  if (/rootWidth|rootHeight/.test(check.field) && typeof check.expected === 'number' && typeof check.actual === 'number') {
    const ratio = check.expected === 0 ? 0 : Math.abs(check.actual - check.expected) / check.expected
    if (ratio <= 0.05) return 'C_BOUNDARY_CASE' as const
  }
  return 'A_TRUE_DRIFT_CANDIDATE' as const
}
```

把 `buildChecks` 里 `rootRadius` 那一行（原 `:470`）替换为：

```ts
  // INFRA-F104 桶 1：比**有效半径**而非字面值。两侧各按自己的盒子 clamp
  // （actual 用量到的宽高，expected 用 Figma 的期望值）。理由与实测见 effectiveRadius 头注释。
  pushNumber(
    checks,
    'rootRadius',
    effectiveRadius(numberFromPx(actual.borderRadius), actual.width, actual.height),
    effectiveRadius(expected.rootRadius, expected.rootWidth, expected.rootHeight),
  )
```

- [ ] **Step 4: 跑单测确认通过**

Run: `pnpm vitest run tests/drift-compare-classify.test.ts`
Expected: PASS（14 个用例）

- [ ] **Step 5: 重跑 render-verification（分类在 spec 侧算，必须重跑才有新 report）**

```bash
lsof -nP -iTCP:5173      # 必须无输出；有则先处理并发 vite server
pnpm test:render-verification
```
Expected: `1 passed`，约 1.5 min

- [ ] **Step 6: 跑闸确认仍绿**

Run: `node scripts/audit-render-drift-gate.mjs`
Expected: `✓ render-drift-gate PASS: A_TRUE_DRIFT_CANDIDATE=0 ≤ baseline 0`，exit 0

- [ ] **Step 7: 核对 report 的三个数变成预期值**

```bash
node -e "const s=require('./figma-data/normalized/render-verification.report.json').summary; console.log(JSON.stringify(s.classifications), 'fail entries =', s.fail, 'total =', s.total)"
```
Expected（与基线 `{A:0,B:304,C:0}` 对比）：
- `A_TRUE_DRIFT_CANDIDATE = 0`（不变，这是硬约束）
- `B_RESIDUAL_SCHEMA_GAP = 300`（304 − 4；那 4 条 switch `rootRadius` 变成真 pass）
- `C_BOUNDARY_CASE = 0`
- `total = 936`

**任一数不符即停下查因，不要改 baseline 迁就。**

- [ ] **Step 8: Commit（含重新生成的 report 产物）**

```bash
git add -- tests/visual-verify/lib/drift-compare-core.ts tests/drift-compare-classify.test.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
git commit -F <msgfile> -- tests/visual-verify/lib/drift-compare-core.ts tests/drift-compare-classify.test.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
git reset -- tests/visual-verify/lib/drift-compare-core.ts tests/drift-compare-classify.test.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
```
msgfile 首行：`feat(render-gate): 分类器改查豁免表 + rootRadius 比有效半径（INFRA-F104 Task 2）`

---

## Task 3: `summarize()` 输出豁免统计

**Files:**
- Modify: `tests/visual-verify/lib/render-report-summary.ts`
- Test: `tests/render-report-summary.test.ts`

**Interfaces:**
- Consumes: Task 1 的 `ExcuseRow` / `globToRegExp`；Task 2 的 `CHECK_FIELDS`
- Produces: `RenderReportSummary` 增字段
  ```ts
  excuse: {
    rows: number
    matched: number
    unmatched: string[]   // "component | field | entryScope" 形式，供闸点名
    excusedChecks: number
  }
  ```

- [ ] **Step 1: 写失败的单测**

追加到 `tests/render-report-summary.test.ts`：

```ts
describe('summarize — 豁免表统计（INFRA-F104）', () => {
  const rows = [
    { component: 'Fixture', field: 'rootWidth', entryScope: '*', reason: 'r', fixDirection: 'harness-verifier', addedAt: '2026-08-07', reviewBy: '2027-02-03' },
    { component: 'Fixture', field: 'rootHeight', entryScope: 'never-matches--*', reason: 'r', fixDirection: 'harness-verifier', addedAt: '2026-08-07', reviewBy: '2027-02-03' },
  ] as never

  const failed = (field: string): CheckResult => ({
    field, expected: 1, actual: 2, pass: false, status: 'fail', classification: 'B_RESIDUAL_SCHEMA_GAP',
  })

  it('命中的行计 matched，没命中的行进 unmatched（shrink-only 的测量口）', () => {
    const s = summarize([entry('e1', 'FAIL', [failed('rootWidth')])], rows)
    expect(s.excuse.rows).toBe(2)
    expect(s.excuse.matched).toBe(1)
    expect(s.excuse.unmatched).toEqual(['Fixture | rootHeight | never-matches--*'])
  })

  it('excusedChecks 数的是 check 不是 entry', () => {
    const s = summarize(
      [entry('e1', 'FAIL', [failed('rootWidth')]), entry('e2', 'FAIL', [failed('rootWidth')])],
      rows,
    )
    expect(s.excuse.excusedChecks).toBe(2)
  })

  it('不传表时 excuse 全零且 unmatched 为空（旧调用点不炸）', () => {
    const s = summarize([entry('e1', 'PASS', [])])
    expect(s.excuse).toEqual({ rows: 0, matched: 0, unmatched: [], excusedChecks: 0 })
  })
})
```

⚠️ fixture 的 `entry()` 辅助函数默认 `figmaName: 'Fixture'`，与上面 `component: 'Fixture'` 对应；若该辅助函数签名变了，**改 fixture 不改判据**。

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/render-report-summary.test.ts`
Expected: FAIL —— `s.excuse` 是 `undefined`

- [ ] **Step 3: 改 `render-report-summary.ts`**

在类型里加：

```ts
  /**
   * INFRA-F104 豁免表的测量口。判据在闸侧（S6 要求 unmatched 为空 = shrink-only），
   * 但**统计在这里算** —— 与 navigationFailures / measuredEntries 同一形态：
   * 闸只做数字比较，不需要知道「什么算命中」。
   */
  excuse: {
    rows: number
    matched: number
    unmatched: string[]
    excusedChecks: number
  }
```

`summarize` 签名与实现改为：

```ts
export function summarize(reports: EntryReport[], excuses: readonly ExcuseRow[] = []): RenderReportSummary {
  // …既有实现不动…
  const excusedChecks = reports.reduce(
    (n, entry) =>
      n + entry.checks.filter((check) => check.status === 'fail' && isExcused(excuses, entry.figmaName, entry.manifestId, check.field)).length,
    0,
  )
  const matchedRows = excuses.filter((row) =>
    reports.some(
      (entry) =>
        entry.figmaName === row.component &&
        globToRegExp(row.entryScope).test(entry.manifestId) &&
        entry.checks.some((check) => check.field === row.field && check.status === 'fail'),
    ),
  )
  return {
    // …既有字段不动…
    excuse: {
      rows: excuses.length,
      matched: matchedRows.length,
      unmatched: excuses
        .filter((row) => !matchedRows.includes(row))
        .map((row) => `${row.component} | ${row.field} | ${row.entryScope}`),
      excusedChecks,
    },
  }
}
```

import 段加：

```ts
import { globToRegExp, isExcused, type ExcuseRow } from './render-excused-fields'
```

- [ ] **Step 4: 改 `manifest-verifier.spec.ts` 的调用点，把表传进去**

在 `writeReports()` 调 `summarize(reports)` 的地方改成：

```ts
summarize(reports, loadExcusedFields(CHECK_FIELDS))
```

并在该文件 import 段补 `loadExcusedFields` 与 `CHECK_FIELDS`。
（React 链 `react-drift-full.spec.ts` 若也调 `summarize`，同样改。若它不调则不动。**先 `grep -rn "summarize(" tests/` 确认全部调用点。**）

- [ ] **Step 5: 跑单测确认通过**

Run: `pnpm vitest run tests/render-report-summary.test.ts`
Expected: PASS（既有 9 条 + 新增 3 条）

- [ ] **Step 6: 重跑 render-verification 并核 excuse 数字**

```bash
lsof -nP -iTCP:5173
pnpm test:render-verification
node -e "console.log(JSON.stringify(require('./figma-data/normalized/render-verification.report.json').summary.excuse, null, 2))"
```
Expected: `rows: 37` · `matched: 37` · `unmatched: []` · `excusedChecks: 300`

**`unmatched` 非空即说明有 stale 行，停下核对 glob，别删判据。**

- [ ] **Step 7: Commit**

```bash
git add -- tests/visual-verify/lib/render-report-summary.ts tests/render-report-summary.test.ts tests/visual-verify/manifest-verifier.spec.ts figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
git commit -F <msgfile> -- <同上逐条>
git reset -- <同上逐条>
```
msgfile 首行：`feat(render-gate): summarize() 产出豁免表命中统计（INFRA-F104 Task 3）`

---

## Task 4: 闸判据 S5 / S6 / S7

**Files:**
- Modify: `scripts/audit-render-drift-gate.mjs`

**Interfaces:**
- Consumes: Task 3 的 `summary.excuse` 形状；Task 1 的表文件路径与 `reviewBy` 语义
- Produces: 无（终端判据）

- [ ] **Step 1: 加 S5 —— `summary.excuse` 形状 fail closed**

在既有 S1–S4 的循环之后插入：

```js
// ── INFRA-F104 豁免表判据（S5–S7）─────────────────────────────────────────────
// S5 只验 report 里有没有豁免统计（旧格式 report / 没接表的跑 → fail closed）。
// **表本身的 schema 校验不在这里** —— 它在 tests/visual-verify/lib/render-excused-fields.ts
// 的 loader 里，违规即 throw、playwright 跑当场炸、report 根本不会更新（再由 S2 抓陈旧）。
// 判据只写一遍，闸不复制一份。
const excuse = summary.excuse
if (
  typeof excuse !== 'object' || excuse === null ||
  typeof excuse.rows !== 'number' ||
  typeof excuse.matched !== 'number' ||
  typeof excuse.excusedChecks !== 'number' ||
  !Array.isArray(excuse.unmatched)
) {
  failed = true
  console.error(
    '✗ render-drift-gate FAIL (S5): report 缺 summary.excuse 或形状不对。\n' +
      '  它由 tests/visual-verify/lib/render-report-summary.ts 的 summarize() 产出；\n' +
      '  report 是旧格式就重跑 `pnpm test:render-verification`。fail closed，不当「没有豁免」处理。',
  )
}
```

- [ ] **Step 2: 加 S6 —— shrink-only**

```js
if (Array.isArray(excuse?.unmatched) && excuse.unmatched.length > 0) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S6): 豁免表有 ${excuse.unmatched.length} 行在本次 report 里匹配不到任何失败 check。\n` +
      '  **修完由闸自己宣布** —— 该缺陷已不再命中，请删掉对应行（表空着是终态，不是待办）。\n' +
      '  若是 glob 写窄了导致漏匹配，改 entryScope；不要为了让闸绿而放宽成 "*"。',
  )
  for (const row of excuse.unmatched) console.error(`    ${row}`)
}
```

- [ ] **Step 2b: 加 S6b —— report 里的行数必须等于磁盘表的行数**

紧接 S6 之后（`excuseRows` 在 Step 3 里读，故本段实现时放在 Step 3 读完表之后；顺序以最终文件为准）：

```js
if (Array.isArray(excuseRows) && typeof excuse?.rows === 'number' && excuse.rows !== excuseRows.length) {
  failed = true
  console.error(
    `✗ render-drift-gate FAIL (S6b): report 里的豁免行数=${excuse.rows} ≠ 磁盘表行数=${excuseRows.length}。\n` +
      '  两个活源不符 = report 陈旧，或 summarize() 的调用点漏传了表（那会让 excuse 统计全零、\n' +
      '  而 S6 因 unmatched 为空照样放行 —— 这正是本判据要堵的假绿）。重跑 `pnpm test:render-verification`。',
  )
}
```

**为什么需要这条**：`summarize(reports, excuses = [])` 的默认值是空表。若某天 `manifest-verifier.spec.ts` 的调用点漏传，`excuse` 会变成全零 —— 而 S5（形状）与 S6（`unmatched` 为空）**都会通过**。分类本身不受影响（`classifyFailedCheck` 的默认参数自己 load 表），所以闸不会红，但豁免统计静默失真。S6b 用「report 自报 vs 磁盘实物」两个活源比对堵住它，形态同 S2。

- [ ] **Step 3: 加 S7 —— 到期即红（闸侧唯一直接读表的判据）**

```js
// S7 是「闸只读数字」这条规矩的**唯一例外**，且刻意只做纯日期比较、不含任何判据语义：
// 到期必须在**不重跑 playwright** 的情况下也能变红（日期一过就该红，而 report 可能是昨天的）。
const excusePath = path.resolve('figma-data/audit-allowlist/render-excused-fields.json')
if (!fs.existsSync(excusePath)) {
  failed = true
  console.error(`✗ render-drift-gate FAIL (S7): 找不到豁免表 ${excusePath} —— fail closed。`)
} else {
  let excuseRows = null
  try {
    excuseRows = JSON.parse(fs.readFileSync(excusePath, 'utf8'))
  } catch (error) {
    failed = true
    console.error(`✗ render-drift-gate FAIL (S7): 豁免表 JSON 解析失败：${error.message}`)
  }
  if (excuseRows !== null && !Array.isArray(excuseRows)) {
    failed = true
    console.error('✗ render-drift-gate FAIL (S7): 豁免表顶层不是数组 —— fail closed。')
  } else if (Array.isArray(excuseRows)) {
    const today = new Date().toISOString().slice(0, 10)
    const expired = excuseRows.filter((row) => typeof row?.reviewBy !== 'string' || row.reviewBy < today)
    if (expired.length > 0) {
      failed = true
      console.error(
        `✗ render-drift-gate FAIL (S7): 豁免表有 ${expired.length} 行已过 reviewBy（今天 ${today}）。\n` +
          '  到期即红是刻意的 —— 让情况被重新裁定，而不是被遗忘。修完删行，或经复核后延期并写明理由。',
      )
      for (const row of expired) {
        console.error(`    ${row?.component} | ${row?.field} | reviewBy=${row?.reviewBy} | ${row?.fixDirection}`)
      }
    }
  }
}
```

- [ ] **Step 4: 改 PASS 时的自印行**

把结尾第二个 `console.log` 之后追加：

```js
console.log(
  `  INFRA-F104 豁免表: ${excuse.rows} 行全部命中（stale=0）· 豁免 check=${excuse.excusedChecks} · 无过期行`,
)
```

- [ ] **Step 5: 更新闸头注释**

在头注释「覆盖面」段之前插入一段（**这是挂载/语义真源，必须写全**）：

```
// ── INFRA-F104（2026-08-07）：豁免判据字段化 ────────────────────────────────
// 此前降级判据是对 entry 的**散文 warning** 做正则（六个短语），命中即该 entry 的
// **任何字段**的任何失配都不再 gate → 实测 188/936 entries = 20.1% 结构上不可能变红。
// 现改为结构化字段级豁免表 figma-data/audit-allowlist/render-excused-fields.json，
// 判据 = (component, field, entryScope) 三元组，散文降级成表里的 reason 注释。
// 三条新判据：
//   S5  report 必须带 summary.excuse（旧格式 / 没接表 → fail closed）
//   S6  豁免表 shrink-only —— 有行匹配不到任何失败 check 即红、点名要求删行。
//       「修完由闸自己宣布」；**表空着是终态，不是待办**。
//   S6b report 自报的行数 === 磁盘表行数（两个活源比对，形态同 S2）。堵的是
//       「summarize() 调用点漏传表 → excuse 全零 → S5/S6 双双放行」这条假绿路径。
//   S7  到期即红（reviewBy < 今天）。
// ⚠️ S7 是「闸只读数字」那条规矩的**唯一例外**：它直接读表文件，但只做纯日期比较、
//    不含任何判据语义。理由 = 到期必须在不重跑 playwright 的情况下也能变红。
// ⛔ 表的 schema 校验**不在本闸**：它在 loader（render-excused-fields.ts）里，违规即 throw、
//    playwright 当场炸、report 不会更新，再由 S2 抓陈旧。判据只写一遍。
// 判据设计（含被否掉的候选与理由，别重新提议）：
//   docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md
```

- [ ] **Step 6: 跑闸确认绿 + 自印新行**

Run: `node scripts/audit-render-drift-gate.mjs`
Expected: exit 0，且输出含 `INFRA-F104 豁免表: 37 行全部命中（stale=0）· 豁免 check=300 · 无过期行`

- [ ] **Step 7: 确认闸在 Node 20 下也能跑（不得 import .ts）**

Run: `grep -n "\.ts'" scripts/audit-render-drift-gate.mjs`
Expected: 无输出（一条 `.ts` import 都没有）

- [ ] **Step 8: Commit**

```bash
git add -- scripts/audit-render-drift-gate.mjs
git commit -F <msgfile> -- scripts/audit-render-drift-gate.mjs
git reset -- scripts/audit-render-drift-gate.mjs
```
msgfile 首行：`feat(render-gate): 闸加 S5/S6/S7 豁免表判据（INFRA-F104 Task 4）`

---

## Task 5: 桶 4 的 code-first divergence 登记

**Files:**
- Modify: `src/design-system/translation/divergences-decisions.json`

**Interfaces:**
- Consumes: Task 1 表里 4 行的 `divergenceRef: "button-url-link-not-carried-by-canonical-button-2026-08-07"`
- Produces: 该 divergence id（供表回指与将来撤销时定位）

- [ ] **Step 1: 读现有形态，照抄结构**

Run: `node -e "const d=require('./src/design-system/translation/divergences-decisions.json'); console.log(JSON.stringify(Array.isArray(d)?d[0]:Object.entries(d)[0],null,2))"`
照它的字段形状写，**不要自创 schema**。

- [ ] **Step 2: 加条目**

id `button-url-link-not-carried-by-canonical-button-2026-08-07`，性质 = 合法 divergence **第 6 类（用户显式 code-first）**，说明必须逐字含：

```
user approved code-first 2026-08-07
```

正文要点（照抄进去）：
- owner 2026-08-07 裁定「URL link 不由 canonical Button 承载」。
- 依据：既有 harness 警告已承认 `Figma URL link variant has its own typography/underline that canonical Button does not implement`；padding（Figma 4 / code 16）与 hover 底色（Figma 无 / code `#434343`）与 typography 属**同一决定的三个面**，只补一半会让 typography 那条照样留豁免。
- 被否的替代路线：canonical Button 补 `style=link` 变体（会碰 `.vue`/`.css`、需 `VISUAL_COMMIT_APPROVED=1`，且属组件保真工作而非闸判据工作）。
- 撤销方式：将来真做 `style=link` 变体时，删本条 + 删豁免表里 4 行 `Button/url link`，闸会自己宣布。

- [ ] **Step 3: 跑 translation 相关闸**

Run: `pnpm audit:translation-completeness`
Expected: exit 0

- [ ] **Step 4: Commit**

```bash
git add -- src/design-system/translation/divergences-decisions.json
git commit -F <msgfile> -- src/design-system/translation/divergences-decisions.json
git reset -- src/design-system/translation/divergences-decisions.json
```
msgfile 首行：`docs(translation): 登记 Button/url link 的 code-first divergence（owner 2026-08-07，INFRA-F104 Task 5）`

---

## Task 6: 故障注入验收（证明不是空过）

**Files:** 无持久改动 —— 全部注入必须复原，复原后 `git status --porcelain` 干净。

**Interfaces:**
- Consumes: Task 1–4 的全部产出
- Produces: 一份逐条结果，写进 Task 7 的收尾记录

**探针名纪律**：本任务用到的探针标识符 `ZzProbeComponent` · `rootProbeField` · `zz-probe-entry` · `ProbeOnlyWidget` 已于 2026-08-07 核过**全仓零命中**。**新增探针名前必须重跑** `grep -rIl --exclude-dir=node_modules --exclude-dir=.git -F "<名字>" .` 并确认为 0，否则表与探针会互相自引用。

- [ ] **Step 1: 阴阳对照 —— 同一 entry 的表外字段必须判 A**

⚠️ **本步已于 2026-08-07 由控制器实跑并订正过两处，下面是订正后的版本，照它做**：
- 原写「荒谬值必须判 A」**不成立**。字段级豁免的语义是「**这个字段**在这些 entry 上豁免」，豁免的是字段不是值 → 表内字段喂任何值都仍是 B，那是设计本身。真正的 kill shot 是**同一 entry 的表外字段**。
- 原写用 `node --experimental-strip-types` 跑**跑不起来** —— `drift-compare-core.ts` 用无扩展名 import（`./render-excused-fields`），靠打包器解析，Node 原生 ESM 报 `ERR_MODULE_NOT_FOUND`。**改用 vitest 跑临时测试文件。**

写临时测试 `tests/_tmp-probe-classify.test.ts`（跑完即删，**不要 commit**）：

```ts
import { describe, it, expect } from 'vitest'
import fs from 'node:fs'
import { classifyFailedCheck, CHECK_FIELDS } from './visual-verify/lib/drift-compare-core'
import { loadExcusedFields } from './visual-verify/lib/render-excused-fields'

describe('阴阳对照 — entry 级免疫已死', () => {
  it('表内字段仍 B，同 entry 的表外字段翻成 A', () => {
    const r = JSON.parse(fs.readFileSync('figma-data/normalized/render-verification.report.json', 'utf8'))
    const rows = loadExcusedFields(CHECK_FIELDS)
    const e = r.entries.find(
      (x: any) => x.figmaName === 'Message' && x.manifestId.includes('status-success') && x.manifestId.endsWith('--theme-dark'),
    )
    const E = { ...e, _warnings: e.warnings }
    const mk = (f: string, exp: any, act: any) => ({ field: f, expected: exp, actual: act, pass: false, status: 'fail' })
    // 前提：这条 entry 的 warnings 确实含旧的 entry 级短语（落地前它整条免疫）
    expect(/harness limitation|verifier resolution|runtime prop/i.test((e.warnings || []).join(' '))).toBe(true)
    // 表内字段：豁免的是字段不是值 —— 荒谬值也仍是 B
    expect(classifyFailedCheck(E as never, mk('rootFillHex', '#0b2b13', '#ff00ff') as never, rows)).toBe('B_RESIDUAL_SCHEMA_GAP')
    expect(classifyFailedCheck(E as never, mk('rootFillHex', '#0b2b13', '#f1fbf3') as never, rows)).toBe('B_RESIDUAL_SCHEMA_GAP')
    // 表外字段：落地前全是 B，落地后必须全是 A
    expect(classifyFailedCheck(E as never, mk('rootGap', 8, 999) as never, rows)).toBe('A_TRUE_DRIFT_CANDIDATE')
    expect(classifyFailedCheck(E as never, mk('rootPadding.left', 16, 0) as never, rows)).toBe('A_TRUE_DRIFT_CANDIDATE')
    expect(classifyFailedCheck(E as never, mk('rootBorderHex', null, '#ff0000') as never, rows)).toBe('A_TRUE_DRIFT_CANDIDATE')
  })
})
```

Run: `pnpm vitest run tests/_tmp-probe-classify.test.ts`
Expected: PASS（1 test）。控制器 2026-08-07 已实跑，五条断言的实测值逐条相符。
跑完 `rm -f tests/_tmp-probe-classify.test.ts`。

- [ ] **Step 2: 致败探针 —— 删行**

删掉表里 `Chart | rootWidth` 那一行 → `pnpm test:render-verification && node scripts/audit-render-drift-gate.mjs`
Expected: 闸 exit 1，报 `A_TRUE_DRIFT_CANDIDATE=12 > baseline 0` 并逐条点名 Chart。
复原：`git checkout -- figma-data/audit-allowlist/render-excused-fields.json`

- [ ] **Step 3: 致败探针 —— reviewBy 过期**

把任一行 `reviewBy` 改成 `2026-08-06`（昨天）→ `node scripts/audit-render-drift-gate.mjs`（**不必重跑 playwright，这正是 S7 的意义**）
Expected: exit 1，报 `FAIL (S7)` 并点名该行。
复原：`git checkout -- figma-data/audit-allowlist/render-excused-fields.json`

- [ ] **Step 4: 致败探针 —— field 写成不存在的名字**

把任一行 `field` 改成 `rootProbeField` → `pnpm test:render-verification`
Expected: playwright **当场失败**，错误含 `不在 buildChecks 的字段闭集里`（loader throw，report 不更新）。
复原后 `git checkout --`。

- [ ] **Step 5: 致败探针 —— field 写成另一个真字段名（拼错的危险形态）**

把 `Chart | rootWidth` 改成 `Chart | rootHeight`（已存在另一行同值 → 会撞重复校验，故改用 `Breadcrumb/Item | textFillHex` → `rootOpacity`）→ 重跑 + 跑闸
Expected: loader **不报错**（`rootOpacity` 在闭集内），但闸报 `FAIL (S6)` 点名该行 stale，**同时** `A_TRUE_DRIFT_CANDIDATE=8 > 0`。两条一起红。
复原后 `git checkout --`。

- [ ] **Step 5b: 致败探针 —— `summarize()` 调用点漏传表（S6b 专用）**

把 `manifest-verifier.spec.ts` 里的 `summarize(reports, loadExcusedFields(CHECK_FIELDS))` 临时改回 `summarize(reports)` → `pnpm test:render-verification && node scripts/audit-render-drift-gate.mjs`
Expected: 闸 exit 1，报 `FAIL (S6b): report 里的豁免行数=0 ≠ 磁盘表行数=37`。
⚠️ 注意此时 `A_TRUE_DRIFT_CANDIDATE` 仍然是 **0**（分类器有自己的默认加载路径，不受影响）—— **这正是 S6b 存在的理由**：没有它，这条路径下 S1–S7 全绿而豁免统计已失真。
复原后 `git checkout -- tests/visual-verify/manifest-verifier.spec.ts`。

- [ ] **Step 6: 致败探针 —— 表改成 `[]`**

`echo '[]' > figma-data/audit-allowlist/render-excused-fields.json` → 重跑 + 跑闸
Expected: `A_TRUE_DRIFT_CANDIDATE=300 > baseline 0`，逐条点名。
复原后 `git checkout --`。

- [ ] **Step 7: 致败探针 —— 表删除 / 写坏 JSON**

`rm` 表文件 → `pnpm test:render-verification`
Expected: loader throw `找不到 …—— fail closed`。
再 `echo '{}' > <表>` → `node scripts/audit-render-drift-gate.mjs`
Expected: `FAIL (S7)`「顶层不是数组」。
复原后 `git checkout --`。

- [ ] **Step 8: 复原核验**

```bash
pnpm test:render-verification && node scripts/audit-render-drift-gate.mjs
git status --porcelain
```
Expected: 闸 exit 0；`git status` 除 report 的时间戳行外无改动（时间戳属正常产物变动，随 Task 7 一起提交）。

**任一条探针没转红 = 判据是空过，停下重设计，不要往下走。**

---

## Task 7: 文档收口

**Files:**
- Modify: `docs/internal/backlog.md`（INFRA-F104 entry）
- Modify: `docs/STATUS.md`（Last updated + 当日摘要）
- Modify: `docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md`（状态改 executed）
- Modify: `figma-data/normalized/render-verification.report.json` + `docs/internal/_generated/render-verification-report.md`（最后一次跑的产物）

- [ ] **Step 1: 更新 backlog F104 entry**

改成已 ship 的形态，**必须写进去的四件**：
1. 落地形态 = 37 行表 + S5/S6/S7 + 有效半径判据；`BASELINE_A` 仍 0。
2. `B_RESIDUAL_SCHEMA_GAP` 304 → **300**，4 条 switch `rootRadius` 转真 PASS。
3. **仍开着的**：Slider ×10 与 Message ×10 是 `code-fix-deferred`，**2026-11-05 到期即红**；桶 4 走 code-first divergence。
4. ⛔ 不变的三条：不给 React 链建闸（[[INFRA-F100]]）· 不改 `prepublishOnly` 格局 · `audit:render-coverage-gaps` 仍零挂载（full-tree 9.7% 覆盖无人守）。

- [ ] **Step 2: 顺手订正 entry 里两处已实测为误的数字**

- 免疫面「14 个组件」→ **13**（`Tab` 在 report 里的 `figmaName` 记作 `Tab List`）。
- 桶 3 归因「`rootFillHex`×6 全属 Disabled=Yes, Hover=Yes」→ **6 条是两个成因**（4 条 disabled+hover，2 条 is-parent），表里已拆成两行。

- [ ] **Step 3: 更新 STATUS.md**

顶部 `Last updated` 改到今天 + 当日摘要（旧摘要 prepend 进 `docs/internal/STATUS-CHANGELOG.md`）。
⚠️ `audit:stale-anchors` **不扫 STATUS.md 发出的链接** —— 新引用的文件路径自己 `ls` 一遍。

- [ ] **Step 4: spec 状态改 executed**

把 spec 头部 `**状态**：设计已定…未实施` 改成 `已实施 <commit>`，并把 §7 的行数从「35 基线 / 37 拆后」改成实测的最终 37 行。

- [ ] **Step 5: 跑全链闸**

```bash
pnpm vitest run
pnpm run audit:status-consistency
pnpm run audit:doc-shape
pnpm run audit:plan-lifecycle
```
Expected: 全部 exit 0

- [ ] **Step 6: 最后一次重跑并跑闸**

```bash
lsof -nP -iTCP:5173
pnpm test:render-verification && node scripts/audit-render-drift-gate.mjs
```
Expected: exit 0，自印 `37 行全部命中（stale=0）· 豁免 check=300 · 无过期行`

- [ ] **Step 7: Commit + push**

```bash
git add -- docs/internal/backlog.md docs/STATUS.md docs/internal/STATUS-CHANGELOG.md docs/superpowers/specs/2026-08-07-infra-f104-render-gate-field-level-excuse-design.md figma-data/normalized/render-verification.report.json docs/internal/_generated/render-verification-report.md
git commit -F <msgfile> -- <同上逐条>
git reset -- <同上逐条>
git push
git ls-remote origin <branch>     # push 落地只信这个
```
msgfile 首行：`docs(status): INFRA-F104 落地收尾 —— 豁免判据字段化（B 304→300，4 条转真 PASS）`

---

## Self-Review

**Spec coverage：**

| spec 节 | 实现它的 Task |
|---|---|
| §3 判据新形状（删 399 / 迁 407 / 换 413 / 保留 398·416） | Task 2 Step 3 |
| §3 有效半径 clamp | Task 2 Step 3 + 单测 Step 1 |
| §4 表 schema | Task 1 Step 3（loader）+ Step 5（数据） |
| §4b entryScope 收窄规则 | Task 1 Step 6（机械核对放大面） |
| §4c reviewBy 策略（90/180 天） | Task 1 Step 5（数据里的日期）+ Task 4 Step 3（S7 执行） |
| §5 四个调用面同表 | Task 2（① 库 API、② React 链经同一 `buildChecks`）+ Task 3 Step 4（spec 调用点）+ Task 1/2 单测（③） |
| §6 S5/S6/S7 + fail-closed 矩阵 | Task 4 + Task 6 Step 2–7 逐条验 |
| §6b 不动 S1–S4 / BASELINE_A | Global Constraints + Task 2 Step 7 核对 |
| §7 落地存量 300 + 4 转 PASS | Task 1 Step 6 + Task 2 Step 7 |
| §7a 桶 4 divergence 登记 | Task 5 |
| §8 被否候选 | 无需实现；Task 4 Step 5 把指针写进闸头注释，防重提 |
| §9a 阴阳对照 | Task 6 Step 1 |
| §9b 致败探针六条 | Task 6 Step 2–7 |
| §9c 探针名纪律 | Task 6 抬头（已核零命中） |
| §10 边界 | Global Constraints |
| §11 派生独立项 | Task 7 Step 1 第 3 点（写进 backlog） |

**Placeholder scan：** 无 TBD / TODO / 「类似 Task N」/ 「加适当错误处理」。所有代码步骤都带可直接粘贴的代码块；37 行表数据逐字给出。

**Type consistency：** `ExcuseRow` / `FIX_DIRECTIONS` / `globToRegExp` / `isExcused` / `loadExcusedFields` / `validateExcuseRows` / `CHECK_FIELDS` / `effectiveRadius` 在 Task 1→2→3→4 的用法一致；`summarize(reports, excuses)` 的第二参在 Task 3 定义、Task 3 Step 4 使用；`summary.excuse` 的四个字段在 Task 3 定义、Task 4 Step 1–4 消费，字段名逐字相同。

**已知的执行期分叉点（不是占位，是需要现场判断的两处）：**
1. Task 3 Step 4：`grep -rn "summarize(" tests/` 若发现 React 链也调 `summarize`，一并改；若不调则不动。**两种都合法，取决于现场 grep 结果。**
2. Task 5 Step 1：`divergences-decisions.json` 的实际 schema 以文件为准，照抄现有条目结构，不自创字段。
