# 处方 — `geometry-consistency` 的两处**分母 / 计数器**缺陷

**来源**：`docs/round2-status.md` §28.6（lab 对 DS 执行 G 格处方的独立复核）撞出，**⛔ 不在原处方
`2026-08-27-designqa-gate-gap.md` 内、也不在 DS 回报里**。
**日期**：2026-08-27 · **判定面**：见 §1 · **⛔ 未改 DS 一个字节**（全程 `git show` / `git archive` 只读）

- **执行状态**：🔴 **三条里两条未执行、一条只修了反方向** —— 亲验 pin **`7fcca917`**（2026-09-01，`lab:N93`），逐条：
  - **A（`collectStateFrameGroups` 按 `WxH` 分组）🔴 未执行**：`scripts/audit-mockup-geometry-consistency.mjs:320` 逐字 ``const key = `${Math.round(k.absoluteBoundingBox.width)}x${Math.round(k.absoluteBoundingBox.height)}` `` ⇒ **仍按宽×高做 key**；`:321-323` 仍 `if (frames.length >= 2)` ⇒ **被丢弃面依旧无任何登记**。⚠️ 本条的应然那一半（「状态帧允不允许不等高」）⛔ 仍 lab 不裁，**但「丢弃面无登记」那一半 ⛔ 不取决于任何设计立场**
  - **A′（`inScopeIds` 不含 state frame id）🔴 未执行**：`:750-754` 逐字 `inScopeIds: new Set([...controls.map(c => c.id), ...rowScan.groups.flatMap(...), ...dividers.map(d => d.id)])` ⇒ **仍不含 state frame 的 id** ⇒ `--probe` 对 G2 全域仍不可断言可达性。⚠️ 本条是三条里唯一**不取决于设计立场**的，⇒ ⛔ 不应与 A 捆绑等待裁定
  - **B（`scanned` 计数器纪律只守了一半）🟡 只修了反方向**：DS 在 `:726-731` 写了本处方的语义（逐字「Reporting `rowGroups: 0` on a controls-only run would say "scanned zero row grids" when the truth is **"never looked"** — the exact ambiguity this gate exists to remove」）并实现 `const rowScanned = wantRows ? {…} : {}`（`:732-738`）—— **但那是 controls-only run 不报 row 计数这个方向**；本处方点名的是**另一个方向**：`--family rows` 跑法下 `:693` `const controls = wantControls ? collectControls(…) : []` ⇒ `controls` 恒空 ⇒ `:743-744` 的 `scanned.controls/buttons` **结构上恒为 0**，且 `:856` **无条件打印** `scanned: 0 control instances / 0 action buttons`。🔴 ⇒ **本处方标题「纪律只守了一半」在修完之后仍然成立，只是换了另一半**
  - ⚠️ **判否强度**：以上三条**只由 pin 上的源码判读得出**。该闸要真跑需 `--file <fileKey>` 调 Figma REST，lab 被明文禁止 ⇒ **⛔ 未实跑该闸**，⛔ 不得写成「已实测确认缺陷仍会发作」
- **执行状态重取**：@`d07be8e9`（2026-09-11 §30 全量重取）—— 🔴 **登记的三条里有一条被翻掉**，逐条现取：
  - **A（按 `WxH` 分组）🔴 仍未执行** —— `scripts/audit-mockup-geometry-consistency.mjs:343`@`d07be8e9` 逐字 ``const key = `${Math.round(k.absoluteBoundingBox.width)}x${Math.round(k.absoluteBoundingBox.height)}``` ⇒ 与登记同，⚠️ **行号从 `:320` 漂 +23**。
  - **A′（`inScopeIds` 不含 state frame id）🟢 已执行 —— ⛔ 登记那句已不成立** —— `:803-809`@`d07be8e9` 逐字含 `...stateScan.groups.flatMap(g => g.frames.map(f => f.id))` **与** `...stateScan.rejectedIds` ⇒ state frame id 已进 `inScopeIds`。
  - **B（`scanned` 计数器纪律只守了一半）🟡 状态不变** —— 那段语义注释现在在 `:761-768`@`d07be8e9`（从 `:726-731` 漂 +35），逐字仍是「Reporting `rowGroups: 0` on a controls-only run would say "scanned zero row…"」。
  - ⚠️ **判否强度不变**：以上仍**只由源码判读**得出。该闸真跑需 `--file <fileKey>` 调 Figma REST、lab 被明文禁止 ⇒ **⛔ 未实跑该闸**。

---

## 0. 一句话

两条都不是「判据写错」，是**「没被检查」被输出成「检查过、结果是 0」**：

| | 缺陷 | 下场 |
|:-:|---|---|
| **A** | `collectStateFrameGroups` 按 `WxH` 分组 | 同宽但**不等高**的 sibling state frame 落进不同 key ⇒ **G2 对它们直接不检查**，且被丢弃面**无任何登记** |
| **A′** | 🔴 `inScopeIds` **从不含 state frame 的 id** | **`--probe` 对 G2 全域无法断言可达性** —— 帧 id 在**故障臂与正常臂上恒红**（零信息）、control id 在故障臂上**给绿**。⛔ 与「不等高算不算状态变体」这个设计立场**无关** |
| **B** | `scanned` 计数器的纪律**只守了一半** | `--family rows` 跑法下 `scanned.controls/buttons` **结构上恒为 0**，把「**从没看**」输出成「扫了零个」 |

🔴 **2026-08-27 已离线实证**（H 格，见 §3.1 的实证订正块）：**A 的危害面比初稿窄**（同组件集时 G1 独立兜底
⇒ 无假绿；只在「label 不同」**或**「G1 被 `baseline`/`allow` 豁免」时才静默通过），
而 **A′ 比初稿宽**（原写「在这一态上覆盖不到」，实为**全域不可断言**）。
⇒ **A 与 A′ 拆开成两条，因为它们的修法与优先级不同**：A 取决于设计立场，**A′ ⛔ 不取决于任何立场**。

**A 的应然那一半（「状态帧允不允许不等高」）⛔ lab 不裁** —— 那是设计规则的内容。
**两条的工程修法在下面**，各带 fail-closed 控制、撞车点、以及「这个改动预设了什么」的显式标注。

---

## 1. 判定面与出处纪律（`AGENTS.md` 第 23 条推论五：引行号必标 sha）

- **DS sha**：取证时固定 `8a341f46`；交付前复取 `1cc95824`（本 session 期间 DS 漂了 **2** 个 commit）。
- ✅ **两 sha 下行号相同**：`git diff --name-only 8a341f46 1cc95824 | grep -i geometry-consistency` = **空**
  ⇒ 该族**四个文件全未变**，本处方所有行号在两 sha 下同时成立。
- **四个文件**（`git ls-tree -r --name-only`）：
  `scripts/audit-mockup-geometry-consistency.mjs` · `scripts/mockup-rules/geometry-consistency.mjs` ·
  `tests/audit-mockup-geometry-consistency.test.ts` · `tests/audit-mockup-geometry-consistency-rows.test.ts`
- ⛔ **未跑该闸 CLI**（`requireFigmaToken` 需 live Figma token）· ⛔ 未跑 `pnpm vitest` · ⛔ 未 build / release。
- ⚠️ **grep 口径写死在命令里**，DS 侧一律排 `.claude/worktrees/`（第 21 条推论二⚠️ / 第 23 条推论二）。

---

## 2. 缺陷 B —— 先说这条，因为**修法方向由 DS 自己的注释决定**

### 2.1 取证（`scripts/audit-mockup-geometry-consistency.mjs` @`8a341f46`＝@`1cc95824`）

`:696-700` **注释逐字立了纪律**：

> The row counters are added ONLY when the rows family actually ran.
> Reporting `rowGroups: 0` on a controls-only run would say "scanned zero row grids" when the truth is
> "never looked" — **the exact ambiguity this gate exists to remove**.

- `:701-707` 对 **rows 侧执行了**：`const rowScanned = wantRows ? { rowGroups, rows, dividers } : {}`
- 🔴 `:711-713` 对 **controls 侧没执行** —— `controls` / `buttons` **无条件**进 `scanned`：
  ```js
  scanned: {
    controls: controls.filter(c => c.kind === 'control').length,
    buttons: controls.filter(c => c.kind === 'button').length,
    ...rowScanned,
  },
  ```
- 而 `:662` 是 `const controls = wantControls ? collectControls(root, cfg, componentIndex) : []`
  ⇒ `--family rows` 跑法下 `controls` 是**空数组常量**、`filter().length` **必然 0**。
- ⚠️ **结论不依赖数据** ⇒ **结构性缺陷**，⛔ 不需要 live token 就能判定。
- lab 离线实测（§28.6，`git archive` 导出纯函数 + DS tracked 三份快照）：三份快照 rows-only 跑法
  **全部**印 `"controls":0,"buttons":0`；而 F104 entry / DS 回报 ② 逐字说默认 config 下是
  「0 control / **21 button**」⇒ **`buttons` 真值 21，闸报 0。**

### 2.2 🔴 危害等级的限定 —— **本处方新查，订正 §28.6 的措辞**

§28.6 把它记成「分母塌」。**实测：它 ⛔ 没有传导到 `checkedUnits`。**
`scripts/mockup-rules/geometry-consistency.mjs` @`1cc95824`：

```js
// :142-146 注释逐字：「分母 = 真正进了分类器的对象。⚠️ 0 是**有意义的读数**，不是"干净"」
const controlUnits = scanned.controls + scanned.buttons                    // :147  🔴 未守
const rowUnits     = wantRows ? scanned.rows + scanned.dividers : 0        // :148  ✅ 守了
const checkedUnits = family === 'rows' ? rowUnits : controlUnits + rowUnits // :149  ✅ 守了
```

- ✅ `:149` 的三元让 **rows 跑法下 `controlUnits` 不进 `checkedUnits`** ⇒ 收割器的
  `empty-denominator` 假绿判定**不受污染**。
- ✅ `:152` 那条「分类器一个控件都没扫到」的警告也带了守：
  `if (probeKeys.some(k => !ROW_PROBES.includes(k)) && controlUnits === 0)`
  ⇒ rows-only 跑法下**不会**误报该警告。
- 🔴 **只有两处对外面没守**：`:84` 的**人读文本**
  （`scanned: ${scanned.controls} control instance(s) / ${scanned.buttons} action button(s)`，
  rows-only 跑法下印出误导性的 `0 control instance(s)`）与 `:179` 的**结构化字段** `extra.scanned`。
- 🔴 **且 `:147` 与 `:148` 是相邻两行的同一个不对称** —— 这是缺陷 B 的**第二个落点**，
  §28.6 只报了闸文件那一处。

⇒ **修正后的等级**：**输出面缺陷**（人读文本 + 结构化字段），**⛔ 不是分母污染**。
⚠️ 但它仍**违反该闸自己 `:696-700` 立的纪律** —— 那段纪律的对象逐字是**结构化字段**
（`rowGroups: 0`），不是文本 ⇒ **`extra.scanned` 恰好落在纪律的正靶心上。**

### 2.3 修法（两种形态 + 代价差，⛔ 不替 DS 选实现）

**(i) 让 `scanned` 的 controls 键也条件化**（与 rows 侧完全对称）— **lab 推荐**

```js
const controlScanned = wantControls
  ? { controls: controls.filter(c => c.kind === 'control').length,
      buttons:  controls.filter(c => c.kind === 'button').length }
  : {}
// ...
scanned: { ...controlScanned, ...rowScanned },
```

- ✅ 彻底消歧：rows-only 跑法下这两个键**不存在**，⛔ 不是 0 ⇒ 满足 `:696-700` 的纪律原文。
- ⚠️ **必须同步改的撞车点（3 处，逐一列出）**：

| # | 位置 @`1cc95824` | 不改会怎样 |
|:-:|---|---|
| 1 | `mockup-rules/geometry-consistency.mjs:147` | `undefined + undefined` = **`NaN`** 进 `controlUnits` ⇒ 比报 0 更糟。修法：加 `wantControls ?` 守，与 `:148` 对称 |
| 2 | `mockup-rules/geometry-consistency.mjs:84` / `audit-*.mjs:825` | 文本印出 `undefined control instance(s)`。修法：整行按 `wantControls` 条件输出 |
| 3 | `tests/audit-mockup-geometry-consistency-rows.test.ts:423` | 逐字 `expect(Object.keys(res.scanned).sort()).toEqual(['buttons','controls'])` —— ⚠️ **它测的是 controls-only 跑法**（`:421` 标题逐字「the controls family stays byte-identical (no row counters leak in)」）⇒ **该断言本身仍然成立、不会转红**；但应**补一条对称的 rows-only 断言**（见 §2.4） |

- ⚠️ **兼容性代价**：`:699-700` 注释逐字说 rows 侧那么做是为了
  「keeps the controls path **byte-identical** for every pre-existing caller」。
  对称改动会让 **rows-only 路径**的输出形状变化 —— 但 `--family rows` 是 **2026-08-27** 才随
  `69153a00` 落地的，**caller 极少** ⇒ 代价窗口现在最小，**越晚改越贵**。

**(ii) 保留 `scanned` 结构，只修 `:147` 的守与两处文本**

- ✅ 改动最小、零测试改动、零 caller 影响。
- 🔴 **代价**：`extra.scanned` 仍对外报 `controls: 0` ⇒ **歧义只在人读层被掩盖，结构化消费者照样踩**
  ⇒ **不满足 `:696-700` 那段纪律的原文**（它管的正是结构化字段）。

### 2.4 fail-closed 控制 / 测试建议（沿用该闸现有形态）

- **must-hit**：新增一条 rows-only 断言 —— `expect(Object.keys(res.scanned).sort()).toEqual(['dividers','rowGroups','rows'])`
  （若取形态 (i)）⇒ **与 `:423` 那条 controls-only 断言构成双向夹逼**（`AGENTS.md` §3.7）。
- **must-not-hit**：断言 rows-only 跑法下 `res.scanned` **⛔ 不含** `controls` / `buttons` 键
  —— ⚠️ 这一侧才是真正拦住回归的那条（§3.2 推论一：只写 must-hit 抓不到这类）。
- **`checkedUnits` 回归**：断言 rows-only 跑法的 `checkedUnits` 与改动前**逐位相同**
  ⇒ 证明本次改动对分母 **no-op**（`:149` 本来就守了，⛔ 不该被顺手改坏）。

---

## 3. 缺陷 A —— `collectStateFrameGroups` 按 `WxH` 分组 ⇒ G2 分母静默缩小

### 3.1 事实（**可实测，lab 判**）· `scripts/audit-mockup-geometry-consistency.mjs` @`8a341f46`＝@`1cc95824`

```js
// :289
const key = `${Math.round(k.absoluteBoundingBox.width)}x${Math.round(k.absoluteBoundingBox.height)}`
// :293-295
for (const [size, frames] of bySize) {
  if (frames.length >= 2) groups.push({ parentId: n.id, parentName: …, size, frames })
}
```

1. **key 含 height** ⇒ 两个 sibling frame 只要**高度不同**就落进不同 key。
2. `:294` 只收 `frames.length >= 2` 的组 ⇒ 单元素组**被丢弃**。
3. ⇒ **G2 对它们直接不检查**（`:330` 是唯一调用点：`for (const group of collectStateFrameGroups(root))`）。
4. 🔴 **被丢弃面无任何登记**：该函数返回**纯数组**，⛔ 无 `rejected` / `unverified` / `checkedUnits` 通道。
5. 🔴 **`--probe` 覆盖不到它**：`inScopeIds` `:719-723` 只含
   `controls.map(c => c.id)` + `rowScan.groups`（含其 rows）+ `dividers.map(d => d.id)`
   ⇒ **⛔ 不含 stateFrameGroups 的 frame**。而 `--probe` 判的正是它（`:797`
   `if (PROBE_ID && !inScopeIds.has(PROBE_ID))`）。
   ⇒ 落在被丢弃 frame 里的 control，其 id **仍在** `inScopeIds` 里（`collectControls` 全树 walk）
   ⇒ **`--probe <该 control id>` 会 PASS，而 G2 从未把它的父帧成组。**

> ### 🔴 2026-08-27 实证订正（H 格，一宽一窄，方向相反）
>
> 原第 5 点标注「结构推理，⛔ 未实跑」。**已离线实证** ——
> 报告 [`reports/2026-08-27-round2-probe-blindspot.md`](../reports/2026-08-27-round2-probe-blindspot.md) ·
> 装置 [`runs/probe-blindspot-evidence/`](../runs/probe-blindspot-evidence/) ·
> 五臂对照 fixture 喂该闸导出的纯函数 @`1cc95824`。⚠️ **`--probe` 判定与 exit code 为逐行复刻，⛔ 仍未跑 CLI。**
>
> **① 盲区比本节写的【更宽】** —— ⛔ 不是「在同宽不等高这一态失效」，而是
> **`--probe` 对 G2 全域从来无法断言**：`sf:1`/`sf:2` 在**五臂全部**都不在 `inScopeIds` 里，
> **包括 G2 成组成功并正确 FAIL 的臂**。
> ⇒ 拿帧 id 当探针**恒红**（五臂全 `exit 2`）⇒ **零信息，⛔ 不是「半个对策」**。
> ⚠️ 二阶后果：运维试一次帧 id 得到红 ⇒ 正确地退回去探 control ⇒ 落进下面那条**给绿**的路径。
> ⚠️ 并使 `tests/audit-*.test.ts:233` 的标题（逐字 `exposes **every classified node id** so --probe can
> assert reachability`）**覆盖面 > 断言覆盖面**（两条断言只覆盖 control `1:1`/`1:2`）。
> **⛔ lab 不裁**「该扩实现还是该改标题」。
>
> **② 危害面比本节写的【更窄】** —— 「同宽不等高 ⇒ 静默通过」**⛔ 不无条件成立**，
> 还需要 **G1 同时失效**。实测三态：
>
> | 情形 | 谁接住 | 复刻 exit | 假绿 |
> |---|---|:-:|:-:|
> | 同宽不等高 + 两 control **同组件集**（= 最常见真实形态） | 🟢 **G1 独立接住** | 1 | ⛔ 无 |
> | 同宽不等高 + **不同组件集、同图层名** | 🔴 无人接 | **0** | 🔴 **有** |
> | 同宽不等高 + 同组件集，**但 G1 被 `baseline`/`allow` 豁免** | 🔴 无人接 | **0** | 🔴 **有** |
>
> **结构解释**：`probeG1:309` 按 `c.label` 分组、人口是**整个扫描面**；`probeG2:335` 按 `c.name` 分组、
> 人口是 **sibling 帧子集**；判据同为 `spread(widths) > widthTolerance`
> ⇒ **同 label 时 G1 的人口是 G2 人口的超集 ⇒ G1 必然也命中**
> ⇒ **G2 在同 label 情形下不提供 G1 之外的检出**，其独有价值恰在「同图层名、不同组件集」这一窄条。
> ⚠️ 而 `baseline` / `allow` 是**按探针**给的（`:116` / `:117-121`）⇒ **给 G1 抬 baseline 或加 allow
> 就会把同组件情形也推进假绿**（已实证）。
>
> ⇒ **本节危害面的正确措辞**：「同宽不等高**且 G1 同时失效**（label 不同 **或** G1 被豁免）时静默通过。」
> ⇒ ⚠️ **这对优先级是个下调信号**（常见形态有 G1 兜底），
> **但对 `--probe` 那条是上调信号**（全域不可断言，⛔ 与设计立场无关）。

⇒ **`lab:N48`「分母塌不许静默降级」的形态**，且是**最难发现的那一种**：分母不是变成 0，是**变小**。

### 3.2 DS 已有的裁定 —— **三处互相加强，⇒ ⛔ 不是「没想到」**

按 `AGENTS.md` 第 20 条第四行 grep（措辞变体表：`刻意 / 为什么 / 重开条件 / hold / 不改 / by design /
intentional / deliberate / by construction / invariant / assumption`）：

| # | 出处 @`8a341f46`＝@`1cc95824` | 逐字 |
|:-:|---|---|
| 1 | `audit-*.mjs:277-279` | `Sibling state frames of one feature: same parent, same width AND height.` +「(Feature state frames are built **equal-height by construction** — that's the **invariant** every TVU multi-state mockup already maintains.)」|
| 2 | 🔴 `tests/audit-*.test.ts:151` | **测试标题**逐字 `only pairs frames of the same size (**different-size frames are not state variants**)`，`:162-163` 双断言 `expect(collectStateFrameGroups(mixed)).toEqual([])` + `expect(probeG2(mixed, cfg, index)).toEqual([])` ⇒ **写成了被测试锁定的契约** |
| 3 | `audit-*.mjs:46-49`（闸头注释） | `every use was intersection / overflow / aspect-ratio / **a grouping key** / a report field — **NOT ONE compared two sibling objects' heights**` ⇒ DS **明确把分组 key 排除在「比较」之外** |

⇒ **答案不是「DS 漏了」，是「DS 认为各状态帧等高是前置不变量」。**

### 3.3 但那三处裁定**没覆盖到三个差**（这才是本处方要交的东西）

**差 1 — 裁定的判定面比缺陷宽，且现有测试面对缺陷那一态零覆盖**
`test.ts:152-160` 那个 `mixed` fixture：helper `:48` 逐字 `const frame = (id, name, children, w = 1228, h = 1077)`
⇒ `f:1` = **1228x1077**（默认）· `f:2` = `frame('f:2', 'PRD card', […], **1000, 1696**)`，**名字还叫 `PRD card`**。
⇒ 被锁定的语义是「**宽高都不同、且明显是另一种东西**」。
🔴 **⛔ 无一条断言覆盖「同宽、仅高度不同」那一态** —— 而那恰是「同一 feature 的状态帧因**内容长短**
而高度漂移」的真实形态（正是 §28.6 在 `DtZc` 快照上撞到 `Frame 3071` 的那一族）。
🔴 **更强的一步（lab 自核撞出，见 §3.4.2 末）**：因为 `f:2` **连宽都不同**，该 fixture
**无法区分「按 `WxH` 分组」与「只按 `width` 分组」两种实现** ⇒ **它对缺陷 A 这一态既无正向覆盖、
也无反向拦截** —— 换句话说，**今天把 key 改成只用 width，整份测试套件不会有任何一条转红。**
⇒ **DS 的裁定用「different-size」这个面，缺陷 A 说的是它的一个更窄的子面，而那个子面上没有闸也没有测试。**
⚠️ **DS 立场的经验来源可考**（⇒ 该裁定有真实依据，⛔ 不是随手写的）：`test.ts:23-25` 头注释逐字
「the real V4-2333 Config-T measurements (hex box 148 vs 120 across **five 1228x1077 state frames**)」
⇒ 它当初面对的真实素材里，五个状态帧**确实**是等尺寸的。

**差 2 — DS 自己为「没报≠没问题」建的对策通道，在这一态上给出假绿**
同文件 `:166-169` 逐字：

> `it('needs the component index: an unclassifiable layer name is invisible to the gate')`
> `// 探针的反面 —— 证明「没报」可能只是没被分类到，不等于没问题。`
> `// 这正是 §M-DISCIPLINE.SCOPE 硬约束 5 要求 --probe 的原因。`

⇒ DS **已建制化地知道**这类问题，对策是 `--probe`（`:82-84` 逐字「assert a node you KNOW must be in
scope actually reached the classifier. Absent probe = the filters are wrong」）。
🔴 **而 `--probe` 对被 `WxH` 丢弃的 state frame 无感**（见 §3.1 第 5 点）⇒ **对策通道在这一态上失效。**

**差 3 — 同一道闸内、同一次改动里的不对称**
DS 在**新写的 rows 侧**建了 `rejected` 登记面：
`collectRowGroups` 返回 `{ groups, rejected }`（`:663` `wantRows ? collectRowGroups(…) : { groups: [], rejected: {} }`）·
`:716` `...(wantRows ? { rowsRejected: rowScan.rejected } : {})` · `:829` 打印 ·
`rows.test.ts:434` 逐字 `expect(res.rowsRejected.modeShareLow).toBeGreaterThan(0)`。
⇒ **DS 自己认可「被分类器拒掉的要登记、要可查」** —— 而 G2 的 `collectStateFrameGroups` 侧没有。

### 3.4 修法 —— **一条无条件成立 + 两条预设应然**

#### 3.4.1 🟢 无条件成立的一条（**lab 推荐**，⛔ **不预设**「不等高算不算状态变体」）

> **给 `collectStateFrameGroups` 补一个 `rejected` 登记面，与 rows 侧完全对称。**

**为什么它不预设应然** —— 两种应然答案下它都该做：

| 若「不等高**仍算**状态变体」 | 若「不等高**不算**状态变体」 |
|---|---|
| 那些帧本该被检查 ⇒ 登记面让它们**浮出来** | 那些帧**确实**该被排除 ⇒ 但排除也该**留痕**，⛔ 不该无声消失 |

⇒ **静默丢弃在两种立场下都是错的。** 这正是 `:696-700` 那段纪律
（「从没看」≠「扫了零个」）与 `lab:N48` 的原意。

**形态建议**（沿用 DS 自己 rows 侧的范式，⛔ 不替 DS 选实现）：

```js
export function collectStateFrameGroups(root) {
  const groups = [], rejected = { singletonSize: 0 }   // 键名由 DS 定
  // …按 WxH 分组不变…
  for (const [size, frames] of bySize) {
    if (frames.length >= 2) groups.push({ … })
    else rejected.singletonSize += frames.length       // ← 唯一新增的语义
  }
  return { groups, rejected }                          // ← 与 collectRowGroups 同形
}
```

**⚠️ 撞车点（必须同步改）**：

| # | 位置 @`1cc95824` | 说明 |
|:-:|---|---|
| 1 | `tests/audit-*.test.ts:162` | 逐字 `expect(collectStateFrameGroups(mixed)).toEqual([])` —— 断言返回值是**数组** ⇒ 改返回结构会**当场转红**。改法：`.groups` 取值，**并新增一条断言 `rejected.singletonSize === 2`** ⇒ 从「返回空」升级为「返回空**且说清为什么空**」。✅ **那个 `2` lab 已核**：`mixed` 的两个 FRAME 各自 size 唯一各计 1；递归进 `f:1`/`f:2` 时其 children 是 `inst()`（`:43` 逐字 `type: 'INSTANCE'`）⇒ `:284-286` 的 `c.type === 'FRAME'` 过滤掉 ⇒ **⛔ 不再累加** |
| 2 | `audit-*.mjs:330` | 唯一调用点 `for (const group of collectStateFrameGroups(root))` ⇒ 改为 `.groups` |
| 3 | `:711-716` | 建议把它并入输出（如 `stateFramesRejected`），与 `rowsRejected` 对称 ⇒ ⚠️ 但这与**缺陷 B 的形态 (i)** 同处一段代码，**两条一起改比分两次改便宜** |

**fail-closed 控制建议**：
- **must-hit**：`mixed` fixture 下 `rejected.singletonSize` **> 0**（⇒ 证明登记面真被写）。
- **must-not-hit**：`stateFrames(148,148)`（等尺寸、正常成组）下 `rejected.singletonSize` **必须 0**
  ⇒ 拦住「把正常帧也记成 rejected」这类反向假阳。
- ⚠️ **新增一个「同宽不等高」fixture**（差 1 那一态），并**显式断言它今天落在 `rejected` 里**
  —— 这条 fixture 的价值是：**无论 DS 后来怎么裁应然，这一态从此都是可见的、有测试盯着的。**

#### 3.4.2 🟡 两条**预设了应然**的备选（并列，**⛔ lab 不选**）

| 修法 | 它预设的立场 | 代价 / 风险 |
|---|---|---|
| **分组 key 只用 `width`**（`${round(width)}`） | 「不等高的帧**仍算**状态变体」 | 会把「同宽但真的是两种不同东西」的帧拉进同一组 ⇒ **G2 假阳上升**，而 **⛔ 没有任何现存测试拦得住这个回归**（见下方 🔴） |
| **height 差异本身转成 finding** | 「不等高**本身就是缺陷**」 | 与 `:277-279` 那个 `invariant` 正面冲突；且它其实是 **R1 的语义**（`R1 row-height-uniform` 已上线）⇒ 有**判据重叠**风险，需先划清 R1 与 G2 的边界 |

🔴 **lab 交付前自核撞出的一处订正（`§18.7`「先证自己的计数器」，这次犯在 lab 自己的推理上）**：
本处方初稿写「只用 width ⇒ `:151` 那条现存绿测试**当场转红**」—— **错的**。
实测 helper `:48` 逐字 `const frame = (id, name, children, w = 1228, h = 1077)`，而 `mixed` 的 `f:2` 是
`frame('f:2', 'PRD card', […], **1000**, 1696)` ⇒ **宽（1000 vs 1228）也不同**
⇒ 只用 `width` 分组它**仍落不同 key、仍返回 `[]`**，**测试仍绿**。

⇒ **这个订正反过来加强了差 1**：该 fixture **无法区分「按 WxH 分组」与「只按 width 分组」两种实现**
⇒ **DS 现有测试面对「同宽不等高」这一态是零覆盖 —— 连间接覆盖都没有。**
⇒ **⇒ §3.4.1 那条「新增同宽不等高 fixture」因此不只是补充，是当前测试面唯一的堵口。**

⇒ **这两条的取舍取决于「状态帧允不允许不等高」这个设计规则问题 ⇒ ⛔ lab 不裁。**
⚠️ **但注意：无论选哪条（含「什么都不选」），§3.4.1 那条登记面都仍然该做** ——
它是**前置**，⛔ 不是三选一里的一个。

### 3.5 ⛔ lab 不裁的那一句（`AGENTS.md` 第 21 条 / 第 20 条推论四：只交两句话的差）

- **句 1（事实，lab 判）**：按 `WxH` 分组 ⇒ **同宽不等高**的 sibling frame 落进不同 key ⇒ G2 不检查它们；
  被丢弃面无登记。🔴 **2026-08-27 实证补两条**：该态**只在 G1 同时失效时**才静默通过（同组件集时 G1 兜底）；
  而 `--probe` 的不可断言性是**全域**的，⛔ 不限于该态。
- **句 2（DS 的裁定，逐字）**：`different-size frames are not state variants` +
  `built equal-height by construction — that's the invariant`。
- **⇒ 差**：句 2 的判定面是「different-**size**」（宽高都不同，且有测试锁定的 fixture 是
  `1228x1077` vs `1000x1696` 的 `PRD card`）；句 1 说的是「**同宽不等高**」这个**更窄的子面**，
  **DS 的测试 ⛔ 未覆盖它**。
- **两者是否等价 —— 即「同宽不等高的帧还算不算同一 feature 的状态变体」—— ⛔ lab 不裁，交 DS / owner。**

---

## 3.6 🔴 缺陷 A′ 的修法 —— `--probe` 对 G2 不可断言（⛔ 与设计立场无关，可独立于 A 修）

> ⚠️ **行号三 sha 锚**（2026-08-27 I 格第三次重取 —— 该闸**又漂了一次**，见下方 ⚠️）：
>
> | 锚 | @`1cc95824` | @`6a78faa4` | @**`f15c8500`**〔现行〕 |
> |---|:-:|:-:|:-:|
> | `inScopeIds` 的 `new Set([` | `:719-723` | `:726-730` | **`:731-735`** |
> | probe 判定 `!inScopeIds.has` | `:797` | `:804` | **`:809`** |
> | `controlPatterns` | `:97-104` | `:104-111` | **`:109-116`** |
> | `probeG1` 按 label 分组那行 | `:309` | `:316` | **`:321`** |
> | `probeG2` 按 name 分组那行 | `:335` | `:342` | **`:347`** |
> | `baseline` | `:116` | `:123` | **`:128`** |
> | `widthTolerance` | `:112` | `:119` | **`:124`** |
>
> ⚠️ **`1cc95824`→`6a78faa4`** = +7 行 / 0 删除（新增 `covers-acceptance:` 头注释）⇒ 整体下移 **7**。
> ⚠️ **`6a78faa4`→`f15c8500`** = **+10 / −5**（I 格实测 `git diff --stat`），**全部落在同一个头注释块**
> （`covers-acceptance:` 的 `[N]` 语义由「最多的那条有几项」改成「Σ 各条」，`[4]`→`[7]`）
> ⇒ **可执行代码零改动**，整体再下移 **5**。🔴 **A 与 A′ 到 `f15c8500` 仍均未被修**，构造式未变。
>
> ✅ **程序化复证（⛔ 不是「读 diff 觉得没影响」）**：装置
> [`runs/probe-blindspot-evidence/run.mjs`](../runs/probe-blindspot-evidence/) 在 `6a78faa4` 与 `f15c8500` 上
> **原样各跑一次**（`DS_TREE=<git archive 导出目录>`，⛔ 不带管道单跑，两次 `EXIT=0`），
> `diff` 两次 stdout = **空**，且与仓内归档 `stdout.txt`（64 行）三方**逐位相同**
> ⇒ **本节全部读数在当前 DS HEAD 上仍然成立**。
>
> 📌 **锚口径澄清（I 格）**：`probeG1`/`probeG2` 那两行锚的是**分组键那一行**
> （`if (!byLabel.has(c.label))` / `const key = c.name`），⛔ **不是** `export function` 声明行
> （声明行在 @`6a78faa4` 是 `:312` / `:335`）。⚠️ 两个面差 4 / 2 行，**引用时别混**。
> 本节其余行号一律以 @`1cc95824` 为准。

**为什么单立**：A 的修法取决于「不等高算不算状态变体」（⛔ lab 不裁）；**A′ ⛔ 不取决于它** ——
无论 DS 怎么裁那个设计问题，`--probe` 都**不该**在「G2 已正确分类并命中」的情形下对帧 id 报
`the filters are wrong`。**恒红与恒绿一样，不携带被测对象的信息。**

**DS 侧三处已立的应然（逐字，@`1cc95824`）**：
`tests/audit-*.test.ts:233` 标题 `exposes **every classified node id** so --probe can assert reachability` ·
`audit-*.mjs:82-84`「**Absent probe = the filters are wrong**, not "the target doesn't exist"」·
`docs/internal/mockup-conventions.md:2995`「**每个过滤条件**…**已用一个已知必然命中的目标当探针验证过**」。
⇒ **A′ 是「纪律写了、执行没覆盖」的第 3 例**，与 A / B 同族。

**形态 (i) — 扩 `inScopeIds`（lab 推荐，⛔ 但不替 DS 拍）**：在 `:719-723` 的 Set 里补上
state frame 的 id（成组的 `group.frames.map(f => f.id)`）。
- ✅ **两条既有断言不会转红**：`tests:236-237` 是 `inScopeIds.has('1:1')`/`has('1:2')` 的**存在性**判定
  ⇒ **往 Set 里增加元素不影响**（此条已核，⛔ 未跑 vitest）。
- ⚠️ **但它只让「已成组」的帧可探** ⇒ 被 `WxH` 丢弃的帧仍探不通 ⇒ **必须与 A 的 `rejected` 登记面配套**，
  否则 `--probe <被丢弃帧>` 仍是「红了但不知为何红」。
- ⇒ **建议同时把 `rejected` 里的帧 id 也放进 `inScopeIds`**，并让 `:797` 的错误文案区分
  「**未被分类器看到**」与「**看到了但被 <理由> 排除**」—— 后者才是 `--probe` 真正要回答的问题。

**形态 (ii) — 改测试标题 + 显式登记不可探面**：把 `:233` 的 `every classified node id` 改成
逐字准确的范围（如 `every classified control / row / divider id`），并在 `--probe` 的用法注释里
写明「⛔ 帧 id 不可作探针」。
- ✅ 代价最小、⛔ 不动判定逻辑；🔴 **但它把缺口从「实现漏了」转成「文档承认漏了」**，
  `--probe` 对 G2 仍无任何可达性断言 ⇒ **§M-DISCIPLINE.SCOPE 硬约束 5 对 G2 这一族仍未满足。**

**⛔ lab 不裁**：选 (i) 还是 (ii)（(ii) 等于接受 G2 无探针覆盖，那是 owner 的风险取舍）·
`rejected` 的键名与文案 · A′ 与 A 的修复先后。

**must-hit / must-not-hit（给执行方，⛔ 少一条就是假绿）**：

| 类 | 条 |
|:-:|---|
| must-hit | 改完后 **在【故障臂】和【正常臂】上都跑 `--probe <帧 id>`** —— 只在故障臂报红才算判别器；**两臂都红 = 恒红 = 零信息**（本处方就是这样抓到 A′ 的） |
| must-hit | `--probe <被丢弃帧 id>` 的报错文案必须能区分「没看到」与「看到了但被排除」 |
| must-not-hit | ⛔ 不许靠「把帧 id 也塞进 `inScopeIds`」来让 `--probe` 变绿而**不**同时补 `rejected` 登记 —— 那是把恒红换成假绿，更糟 |

---

## 4. 两条缺陷的共同 pattern（⇒ 这才是可复用的那部分）

**这道闸把「没被检查」输出成「检查过、是 0」的两条路径，都在它自己已经立过纪律的地方。**

| | 纪律在哪 | 守了哪一半 | 漏了哪一半 |
|:-:|---|---|---|
| **B** | `:696-700`（**闸自己的注释**，逐字「the exact ambiguity this gate exists to remove」） | rows 侧 `:701-707` ✅ · 消费点 `:148`/`:149`/`:152` ✅ | controls 侧 `:711-713` 🔴 · 消费点 `:147` 🔴 · 输出 `:84`/`:179` 🔴 |
| **A** | `:166-169`（**闸自己的测试注释**，逐字「『没报』可能只是没被分类到，不等于没问题」）+ rows 侧 `rejected` 范式 | rows 侧 `rowsRejected` ✅ | stateFrameGroups 侧 🔴 无登记面 |
| **A′** | `tests:233` 标题逐字 `every classified node id` + `:82-84`「Absent probe = the filters are wrong」+ `mockup-conventions.md:2995` 硬约束 5 | controls / rows / dividers 三类 ✅ 都在 `inScopeIds` 里 | 🔴 **state frame 一类都不在** ⇒ `--probe` 对 G2 恒红（**已实证，五臂全 exit 2**） |

⇒ **两条都不是「DS 不懂这个道理」，是「道理写下来了、执行覆盖不全」。**
⇒ 因此两条的修法都**⛔ 不需要 lab 引入任何新概念** —— 只是把 DS 自己的范式补齐到另一半。
⚠️ 且**两条落在同一段代码**（`:701-723`）⇒ **建议一次改完**，分两次改要动同一段两遍。

---

## 5. ⛔ lab 的边界（如实登记）

1. ⛔ **未改 DS 任何文件** —— 全程 `git show` / `git archive` 只读；DS dirty 起止均 **2**（既存两项）；
   四 pin 起止全 **dirty=0**。
2. ⛔ **未跑该闸 CLI**（`requireFigmaToken` 需 live Figma token）· ⛔ 未跑 `pnpm vitest`
   ⇒ **本处方所有「改了会转红 / 不会转红」的判断均为读源码推理，⛔ 未实跑证实。**
3. ⛔ **不裁**：「状态帧允不允许不等高」（§3.5）· §3.4.2 两条备选选哪个 · 两条缺陷的修复优先级 ·
   `rejected` 的键名与输出形状。
4. ~~⚠️ **§3.1 第 5 点（`--probe` 假绿）是结构推理**~~ ⇒ ✅ **2026-08-27 已离线实证（H 格）**：
   五臂对照 fixture 喂该闸导出的纯函数 @`1cc95824`，见
   [`reports/2026-08-27-round2-probe-blindspot.md`](../reports/2026-08-27-round2-probe-blindspot.md)。
   🔴 **实证同时改写了本处方两处**：危害面**收窄**（同组件集时 G1 兜底 ⇒ 无假绿）·
   probe 盲区**扩宽**（全域不可断言 ⇒ 新拆出缺陷 **A′**，修法见 §3.6）。
   ⚠️ **仍未跑的**：CLI 本体（`--probe` 判定与 exit code 为逐行复刻）· `pnpm vitest` ·
   live Figma 上「同宽不等高 + label 不同」这一态**真实发生率**（⛔ lab 够不到，交 DS / owner）。
5. ⚠️ **按措辞 grep，⛔ 不保证穷尽**（第 21 条推论一）：§3.2 那张裁定表用的是 11 个措辞变体
   × 该族四个文件的判定面；**换判定面（如全仓 / `docs/`）可能有更多裁定，本处方未扫。**
6. ⚠️ **缺陷 B 的危害等级已由本处方 §2.2 下调**（从 §28.6 记的「分母塌」改为「输出面缺陷」）
   —— 原措辞未查 `mockup-rules/geometry-consistency.mjs:149` 那个三元。**这是 lab 自己的订正。**
7. ⚠️ **本处方初稿自己出过一处假阳，被交付前自核拦下**（§3.4.2 末，逐字记录未删）：
   初稿断言「只用 width ⇒ `:151` 当场转红」，实测 `mixed` 的 `f:2` **连宽都不同** ⇒ 不会转红。
   ⇒ `§18.7`「⛔ 与对方自称对立前先证自己的计数器」**第 8 次生效**，且**首次犯在 lab 自己的推理上
   而非别人的读数上** —— 保留在正文里，因为那次订正**改变了差 1 的强度**（从「未覆盖」升级为
   「换实现整套测试都不会红」）。
