# 处方 — 解 `prepublishOnly` 的 fail-fast 遮蔽，让 `rule-hit` 的分母可比

> 对应 spec §14 **第二轮第 9 步**。
> 归因基线 pin `339a72bf` → 落地 pin `4a68e02d`；本处方在 **`4a68e02d`** 上写。
> 前置实测：[`reports/2026-08-25-round2-step7-7b-delta.md`](../reports/2026-08-25-round2-step7-7b-delta.md) §6。

- **执行状态**：🟢 **已由 DS 落地** —— 亲验 pin **`7fcca917`**（2026-09-01，`lab:N93`）。`package.json:96` 现为 `"prepublishOnly": "node scripts/run-gate-chain.mjs"`；该文件 `:1` 逐字「闸链的**收集式 runner**：**跑完全部步骤，再决定退出码**」⇒ 命中本处方 §3.2 点名的形态（收集式 runner，⛔ 不是 shell 拼接）。且 `:121-126` 有 fail-closed 控制守住入口不被改回 fail-fast（逐字「链的入口被改回 fail-fast 形态，分母恒定这件事当场失效。fail closed」）—— 正是本处方 §5.1「分母恒定是唯一目的，缺此项即失败」那条的机器化。`:34` 亦逐字排掉了「把 `&&` 换成 `;` 或 `|| true`」那条伪解法
- **执行状态重取**：@`d07be8e9`（2026-09-11 §30 全量重取，量具 `metrics/proposal-execution-status-refresh.mjs`）—— 锚 4/4 仍成立（`package.json`，⚠️ 行号已漂） ⇒ **结论不变**。

---

## 0. 一句话

**第 9 步收窄成一件事：让每条闸在每次 run 里都有机会上场，从而使 `rule-hit` 的分母可比。**

⛔ **不做**「分组并行」，⛔ **不做**「按成本重排」—— 这两条的前提在 `4a68e02d` 上实测塌了（§1）。

---

## 1. 先说砍掉了什么，以及为什么（⛔ 别照 spec §10.3 抄）

spec §10.3 给第 9 步定的是三个动作。实测后**只剩一个**：

| 原定动作 | 实测 | 结论 |
|---|---|---|
| **按成本重排** | 全链热态 **8.7 s**（N=3，σ ≈ 0.1 s），**单条 `audit:plan-lifecycle` 占 70%（6.2 s）** | ⛔ **砍掉。** 重排一条 8.7 s 的链，省下的时间还不如治那一条闸 |
| **分组并行** | 理论最优并行下界 = 最长单步 = **6.2 s** ⇒ 天花板收益 **2.7 s / 30%** | ⛔ **砍掉。** 代价（错误定位变难、日志交织、CI 语义重写）远超 2.7 s |
| **解 fail-fast** | N10：164 条失败 run **平均 9.1 条闸没跑到**，跑到比例中位 **56%**、最低 **26%**（通过 run 恒 100%） | ✅ **保留，且是唯一保留的** |

**⚠️ §10.3 是在一张已被推翻的图景下写的**：它当年的前提是「13 个 mockup gate 解析同一批 625 份 HTML，
同一批文件被解析 13 遍」（E3 推翻了 625，E4 推翻了「解析同一批 HTML」）。
去掉那个前提，「链太贵所以要并行」这条动机就没有了 —— 链根本不贵。

**⇒ 本处方的理由与「省时间」无关。** 它要治的是**读数不可比**：
一条闸在失败 run 里没跑，与它跑了但 0 命中，在朴素「0 命中」口径下**长得一模一样**（N10）。
`rule-hit` 建在这个分母上，就是建在流沙上。

---

## 2. 病灶的精确形态

`package.json` 的 `prepublishOnly` 是 **39 步顶层 `&&` 串行**（`4a68e02d` 处逐字未变）：

```
pnpm run audit:icon-fill-currentcolor && pnpm run audit:icon-category-enum && … && pnpm run audit:figma-env-single-source
```

- `&&` ⇒ 第 k 步非 0 退出，**第 k+1 … 39 步一步都不跑**。
- 于是「某条闸这次没有产生读数」有**三种**互不相同的成因，而它们的**观测形态相同**：

| 成因 | 真实含义 | 处方 |
|---|---|---|
| 未挂载 | 这条闸根本没有执行点 | 接执行点（第一轮已盘点） |
| 已挂载、0 命中 | 真的跑了，真的干净 | 反事实实验（第 12 步） |
| **已挂载、被 fail-fast 遮蔽** | **压根没上场** | **本处方** |

⇒ **在解开 fail-fast 之前，第 12 步的候选名单不可取**（会把第三类误当成第二类）。

---

## 3. 目标形态

**让链「跑完全部 39 步，再决定退出码」**，而不是「遇错即停」。

### 3.1 判据：什么叫「解开了」

不是「把 `&&` 换成 `;`」——那会**丢掉阻断语义**，等于把闸变成告示牌。
要同时满足两件事：

1. **每条闸在每次 run 里都被执行**（曝光分母 = 39，恒定）
2. **只要有任一条闸失败，整体退出码仍非 0**（阻断语义不变）

### 3.2 形态选择：**收集式 runner**，不是 shell 拼接

⛔ **不要**走 `set +e; cmd1; rc1=$?; cmd2; rc2=$?; …` 这种 shell 拼接：
39 步手写退出码收集，是一份**会 stale 的第二真源**（DS 自己在 `audit-plan-lifecycle`
里为「硬编码清单会 stale」付过学费，源码 :107 注释逐字写着）。

**改为一个 runner 脚本**（`scripts/run-gate-chain.mjs`，建议名）：

- **步骤面真源仍是 `package.json`** —— runner **从 `prepublishOnly` 自己解析**出 39 步，
  ⛔ 不在 runner 里再抄一份清单。
  （lab 侧 `adapters/ds-gate-chain-run.mjs` 已实证这条解析可行且可 fail-closed。）
- 逐步执行，**收集每步的 exit code + 耗时 + stdout 尾部契约块**。
- 全部跑完后：**任一步非 0 ⇒ runner 非 0**。
- 打印一张**逐步表**：`#index · npmKey · exit · ms · findings · checkedUnits`。
- `prepublishOnly` 改成 `node scripts/run-gate-chain.mjs`（**一处改动**）。

> 这样第 7b 步的收割器就不必再自己去跑一遍链 —— runner 本身就是收割点。
> 但**本处方不合并两者**（收割器有自己的选择面与棘轮表），只要求 runner 的
> 输出能被收割器直接消费。

### 3.3 ⛔ 三条不能在改造中丢的语义

1. **失败要能定位。** 39 步全跑完再报，日志会很长。⇒ runner 结尾必须打一段
   **只含失败步**的摘要（步号 + npmKey + exit + stderr 尾部若干行）。
   ⛔ 不许只打一个总退出码。
2. **代价上限要有。** 解开 fail-fast 后，失败 run 的耗时从「跑到失败点为止」变成「全链 8.7 s」。
   这是**本处方的真实成本**，要在处方里明说，⛔ 不许假装是免费的。
   （实测量级：失败 run 现在平均只跑 56% ⇒ 解开后失败 run 慢约 1.8 倍，绝对值约 +3.8 s。可接受。）
3. **`lint:ds` 不是闸**（E14）。它在链里但不属于 `rule-hit` 的分母。
   runner 要能把它标出来，⛔ 别让它进闸的计数。

---

## 4. 顺带必须处理的一条：`audit:plan-lifecycle` 占全链 70%

**不在本处方范围内解决，但必须在这里登记**，否则解开 fail-fast 后
「每次 run 都跑满 39 步」会让这 6.2 s 从「有时付」变成「每次付」。

实测：`audit-plan-lifecycle.mjs` 热态 **6,165 ms**（N=3：6,165 / 6,215 / 6,165），做全仓引用扫描 ——
`REF_SCAN_DIRS` 12 个目录逐文件读文本，其中 `figma-data/` 一个目录 **67 MB**
（`docs/` 10 MB、`src/` 3.6 MB）。

**建议单独立一条 backlog**（owner 裁定，⛔ lab 不替 DS 决定实现）。可能方向，按代价排序：

1. `figma-data/` 是否真的需要进引用扫描面 —— 它是数据快照，不是指令型文档。
   ⚠️ 但 REF_SCAN_DIRS 的判据是「哪里可能引用到指令型文件」，删目录要先证明**零真引用**，
   ⛔ 不能因为它大就删（那正是 E20 那类「下界漏形态」的反向错误）。
2. 只读一次、缓存到 `_generated`，按 mtime 失效。
3. 只在 `docs/internal/_plans` 等目录有变更时才跑（`paths:` 过滤思路）。
   ⚠️ 这会让它变成「有时不跑」—— **与本处方要建立的「分母恒定」直接冲突**，
   ⇒ 若走这条，必须同时把「本次跳过」显式发进契约块（`checkedUnits: 0` + 一个 `skipped` 标记），
   ⛔ 不能静默不跑。

---

## 5. 验收标准

⛔ 每条都要可复核证据，不接受「已改完 / 通过」式断言（AGENTS §4）。

### 5.1 分母恒定（这是本处方的**唯一目的**，缺此项即失败）

- [ ] **构造一次真实失败**：让**排在第 1 位**的闸（`audit:icon-fill-currentcolor`）失败，
      验证第 2 … 39 步**全部仍然执行**。
      ⛔ 证据不是「runner 说它跑了」，而是**逐步表里 39 行都有 exit 与耗时**。
- [ ] 同一次运行 runner 的**总退出码非 0**（阻断语义没丢）。
- [ ] **must-not-hit**：全绿时 runner 退出码 **0**，且逐步表仍是 39 行
      （⛔ 别做成「只有失败时才打表」）。

### 5.2 步骤面不是第二真源

- [ ] 在 `package.json` 里**新增一步**闸，**不改 runner 一个字**，验证逐步表变成 40 行。
- [ ] **must-not-hit**：把某步的 npm key 改成不存在的名字，runner 必须**当场抛/非 0**，
      ⛔ 不许静默跳过（fail closed，AGENTS §3.1）。

### 5.3 失败可定位

- [ ] 同时让**第 1 步与第 39 步**失败，验证结尾摘要**两条都列出**（步号 + npmKey + exit + stderr 尾部）。
- [ ] **must-not-hit**：只有 1 条失败时，摘要里**不得**出现第二条（⛔ 别把 warning 混进失败摘要）。

### 5.4 与 7b 契约的接线

- [ ] 逐步表里，11 条已上契约的闸**各自打出 `findings` 与 `checkedUnits`**
      （对照表见 delta 报告 §4.3，⛔ 给对照表不给总数）。
- [ ] **must-not-hit**：`lint:ds` **不得**出现在闸的计数里（E14）。
- [ ] 收割器 `report:gate-output-harvest` 在 runner 改造后**仍然 exit 0 且收割面仍是 11**
      —— 若变了，说明 runner 改动动了 stdout，要查。

### 5.5 成本如实登记

- [ ] 重测全链热态总耗时（`--repeat ≥ 3`，**丢弃第一次**），写进 runner 头注释。
- [ ] **失败 run 的耗时也要测一次**（构造第 1 步失败），并与「改造前失败即停」的耗时对照 ——
      这是本处方的**代价**，⛔ 不许只报收益。

### 5.6 ⛔ 不算验收通过的情形

- **把 `&&` 换成 `;` 或 `|| true`。** 那是丢阻断语义，不是解 fail-fast。
- **runner 里抄一份 39 步清单。** 步骤面必须仍以 `package.json` 为唯一真源。
- **只在本地跑通。** 两套 workflow（`.github/` 与 `.gitea/`）里凡调 `prepublishOnly` 的地方
  都要跟着核一遍 —— ⚠️ 但 GitHub Actions 窗口 2026-08-14 已关闭（N14），
  ⇒ **CI 侧只能静态核对 workflow 文件，跑不出来**。这一条如实登记为边界，不是漏。

---

## 6. 登记的边界与已知未知

1. **本处方不改任何闸的判据。** 只改「谁有机会上场」。
2. **解开后 `rule-hit` 也还不能直接用**：契约目前只覆盖在链 **11/38** 条闸
   （v2 那 25 条 `scripts/` 闸仍读不出计数，棘轮表已立但一格未缩）。
   ⇒ 分母可比是**必要不充分**条件。
3. **历史语料不会因此变得可比。** CI 日志里那 164 条失败 run 的遮蔽是**既成事实**，
   解开 fail-fast 只对**此后**的 run 有效。⇒ `rule-hit` 的时间序列在改造点上有一个**口径断点**，
   必须显式标注，⛔ 不许跨断点直接比。
4. **`audit:plan-lifecycle` 的 6.2 s 不在本处方内解决**（§4），但它会被本处方**放大**
   （从有时付变成每次付）。两件事要一起排期。
5. **并行没有被永久排除**，只是当前 ROI 不成立。若哪天 `plan-lifecycle` 治好、
   链的成本画像变平（没有单点占比 >50%），并行的账要重算。
