# 处方 — 统一闸的 stdout 输出契约

- 日期：2026-08-24 · 对应 spec §14 **第二轮第 7b 步**（§7.3 方案 (b) 的改写，勘误 E13）
- 目标 sha：`71ac2711`（lab 的 pin；DS 侧执行时按当时 HEAD 重核）
- 执行方：**DS 侧 session 在真源执行**。lab 只出处方（§8 只读边界）
- 实测依据：`reports/2026-08-24-gate-output-contract.md`（⛔ 不在此复制证据）
- 依赖：与第 7 步**可并行**；与 `rule-hit` **已解耦**（E13）。真前置是第 9 步（fail-fast 分母，N10）
- **执行状态**：🟢 **已由 DS 落地** —— pin **`4a68e02d`**（2026-08-27 I 格补登记，见下方）
- **执行状态重取**：@`d07be8e9`（2026-09-11 §30 全量重取，量具 `metrics/proposal-execution-status-refresh.mjs`）—— 锚 `scripts/lib/gate-output-contract.mjs` 现取仍在 ⇒ **结论不变**。

---

> ### 🟢 2026-08-27 执行状态回改（I 格，`lab:N59`）—— **本处方已被执行，⛔ 上面第 5 行那句「DS 侧 session 在真源执行」是未来时态，别照它读成「还没做」**
>
> **lab 亲验读数（pin `4a68e02d`，⛔ 不是转引 status）**：
> - `scripts/lib/gate-output-contract.mjs` **存在**，导出面逐字对上本处方 §1 契约 v1：
>   `GATE_OUTPUT_CONTRACT_VERSION = 1`（`:54`）·
>   `CONTRACT_REQUIRED_TOP_KEYS = ['auditId','checkedAt','totals','findings']`（`:57`）·
>   `CONTRACT_REQUIRED_TOTALS_KEYS = ['findings','checkedUnits']`（`:59`）
>   ⇒ **§1.1 那个新增键 `checkedUnits` 已进契约**。
> - **§3.2「收割器必须自带交叉校验，不一致时 fail closed」已落地**：
>   `crossCheckExitVsTotals()`（`:240`）+ `CROSS_CHECK_ANOMALIES = ['blocked-without-findings','empty-denominator']`（`:279`）。
> - `scripts/gate-output-harvest.mjs` **存在**（= §3 收割器）；该 lib 在 pin 上被 **14 个文件**引用。
>
> ⛔ **本次未做的事（如实登记，⛔ 别把上面读数读成「验收通过」）**：
> - ~~⛔ **未逐条跑 §4 验收清单** —— `§4.1` / `§4.2` 那 8 个 `- [ ]` 仍未勾选，本次只验「产物存在 + 契约常量对得上」，
>   ⛔ **没跑全链**、⛔ 没验 `auditId` 集合无重复无缺失、⛔ 没构造 §3.2 那五种 `exit × findings` 组合。~~
>   🔴 **2026-09-15 订正：上面三句里有【两句半】与实测相反 —— 8 条里 7 条早在 2026-08-25 就跑过并留了证。**
>   ⛔ **勾没打 ≠ 没跑**。逐条证据全在 `reports/2026-08-25-round2-step7-7b-delta.md` @pin `4a68e02d`：
>
>   | §4 的框 | 状态 | 证据（逐字定位）|
>   |---|:-:|---|
>   | `:160` 11 条五项对照表 | ✅ 跑过 | `…7b-delta.md:273-285` 整张表（11 行 × `auditId`/`checkedAt`/`totals.findings`/`totals.checkedUnits`/`findings[]`）|
>   | `:162` `auditId` 集合无重复无缺失 | ✅ 跑过 | `:287` 逐字「**`auditId` 集合无重复、无缺失**（11 个各不相同，实跑核过）」+ `:288` 订正「与 npm key 并非字面相同」（新发现 N26）|
>   | `:163` `findings:0` 也打 `totals` | ✅ 跑过 | `:290` 逐字「表里 7 条 findings=0 全都带 `totals`」|
>   | **`:164` 阻断行为与合并前逐条一致** | ~~🔴 未跑~~ ⇒ ✅ **2026-09-15 跑完** | 新量具 `metrics/chain-exit-parity.mjs`（`--from chain-run-339a72bf --to chain-run-4a68e02d`）⇒ **交集 39/39（`onlyFrom 0 · onlyTo 0` ⇒ 步骤集合完全相同）· exitCode 不一致 `0` · `exitStable=false` `0`**。4 条内建控制全绿，含**阳性对照**（注入 `audit:icon-fill-currentcolor` +99 ⇒ 报出**恰好 1 条**差异 ⇒ ⛔ 不是恒绿）与**对照臂**（自比 0 差异 ⇒ ⛔ 不是恒红）|
>   | `:168` 全链跑、收割器读出 11 条 | ✅ 跑过 | `:310` 逐字「lab **独立实现**的解析器同样读出 11/11…DS 收割器自报 11/11，两者一致」|
>   | `:170` must-not-hit `report:gate-regression-face` | ✅ 跑过 | `:312` 逐字「收割器**自己内建了这条控制**…lab 另跑一次实测收割面 11 条、不含它」|
>   | `:171` 五种 `exit × findings` 组合 | ✅ **已闭环** | `:313` 当时记「⚠️ 部分成立」⇒ 缺口造故障 B 抓出 ⇒ 转 `proposals/2026-08-25-crosscheck-denominator-gap.md` ⇒ **已由 DS 落地**（亲验 pin `7fcca917`；修法在 `scripts/lib/gate-output-contract.mjs` 的 `checkedUnits === 0` 分支 + `tests/gate-output-contract.test.ts` 一整个 describe）|
>   | `:172` 散文前缀 + 末尾 JSON（两条 must-hit）| ✅ 跑过 | `:313` 逐字「两条在 lab 的独立解析器下均正确解析」|
>
>   ⇒ ~~🔴 **真实剩余射程 = 1 格（`:164`），⛔ 不是 8 格。**~~
>   ⇒ ✅ **2026-09-15：那 1 格也跑完了 ⇒ §4 的 8 个框现在 8/8 全部有据，已逐个勾选并在框后标了证据出处。**
>   ⚠️ **口径写死（⛔ 别用现 HEAD 重做这一格）**：「合并前」= pin `339a72bf`（2026-08-24）、
>   「合并后」= pin `4a68e02d` —— 拿现 HEAD 当「合并后」量到的是 21 天的无关漂移
>   （`AGENTS §2.13`：pin 目标 = 本轮处方线的最后一个 commit，⛔ 不是对方当前 HEAD）。
>   ⚠️ **量具已登记的边界（⛔ 是边界不是 TODO）**：① `parity` **只对两侧都有的 npmKey 成立**，
>   新增/消失的步骤**没有对照面** ⇒ 那是「无从比较」⛔ 不是「一致」（本次两侧集合恰好相同，
>   所以这条边界这次没咬到）；② **exit code 相同 ⛔ 不等于行为相同** —— 一条闸可以「都 exit 0」
>   而 findings 从 3 条变 0 条，那是契约判据的活、⛔ 不在本量具射程内。
> - ⇒ ~~**`- [ ]` 保持未勾选是正确的**~~ ⇒ ✅ **2026-09-15 已全部勾选**；
>   「整份处方待执行」这个读法**早已失效**。
>   🔴 **2026-09-15 再补（这条教训⛔ 别删）**：「未勾选」这个**信号本身曾经失去分辨力** ——
>   7 个跑过的框和 1 个没跑的框**长得一模一样**，而处方正文还写着「三句未做」里有两句半是反的。
>   ⇒ 这正是本处方自己 `:51-52` 说的那个病（`AGENTS.md` 第 25 条推论三在存量上未执行），
>   只不过那次是**在同一份文件内部**复发（`lab:N56`）。
>   ⇒ **操作口径：跑完一格就当场勾一格并在框后标证据出处**，⛔ 别攒到最后 ——
>   攒着的代价不是「忘了勾」，是**下一格读它时会重跑 7 个已经跑过的东西**。
>
> 📌 **为什么这条迟到**：`docs/round2-status.md:15` 早在第 7b 步落地当天就记了「✅ 已落地 `4a68e02d` · lab 已验收（§7）」，
> 而**本处方正文此前零处登记**（I 格实测：执行状态类留痕 **0 处**）⇒ `AGENTS.md` 第 25 条推论三那条口径在存量上未执行。
> 🔴 **根因不是「忘了」**：9 份处方**结构性都没有「执行状态」这一栏** ——
> `status` 里「待 DS 执行」有 **7 处**，`proposals/` 全 9 份 **0 处**。⇒ 已立 `lab:N59`。

## 0. 一句话

**不是「给 38 条闸加埋点」，也不是「推广已有的 8 条示范」——
是「批准 `figma-sync/` 那一代闸已经在做的事，让外层收割一次；`scripts/` 那 25 条另立棘轮」。**

原计划把这两件事合并成一句话，而实测它们成本差一个数量级：
`figma-sync/` 11 条在链闸里 10 条已发结构化输出，`scripts/` 25 条在链闸里 **0 条**。

---

## 1. 契约 v1

形状取 pin 处**已有的 3 条**的交集（`figma-sync/audit-icon-fill-currentcolor.mjs` /
`audit-component-no-inline-svg.mjs` / `audit-no-hardcoded-design-tokens.mjs`）——
**不新造形状**，这是「批准既有实践」而不是「引入新规范」：

```jsonc
{
  "auditId": "icon-fill-currentcolor",   // 必填。收割器靠它自证「这是哪条闸」
  "checkedAt": "2026-08-24T…Z",          // 必填
  "totals": {
    "findings": 3,                        // 必填。⚠️ 0 也必须打出来，不能省
    "checkedUnits": 812                   // 必填（新增）。见 §1.1
  },
  "findings": [ … ]                       // 必填，可为空数组
  // 其余键各闸自便（perFile / rule / summary …），契约不管
}
```

**发射规则**：

1. 走 **stdout**，且是**最后一条 stdout 输出**（收割器取末尾那个 JSON 块）。
2. **无条件发射** —— 不藏在 `--json` 开关后面。
   （理由见 §3 的 N17：带 `--json` 的 10 条闸**全部不在链**，在链闸一条都没有 ⇒ 靠开关收割等于收割不到。）
3. **失败明细继续走 stderr。** 契约只规定 stdout 的那一块；
   人读的散文与失败逐条明细留在 stderr，两条流各管各的。
   （实测形态：`audit-translation-completeness.mjs:517-521` 的失败明细本来就在 `console.error`。）
4. **exit code 语义不变。** 契约只加输出，不改任何闸的阻断行为。

### 1.1 为什么必须有 `checkedUnits`（新增的那个键）

现有 3 条样板只有 `totals.findings`。但 **`findings: 0` 有两种成因**：真干净，还是**分母是空的**。
第一轮的挂载盘点已经把这条登记成边界（「不判断闸跑起来分母是不是空 —— 无参恒 exit 0 的假绿看不见」），
DS 自己也为同类问题付过学费（`audit-mockup-handoff-evidence.mjs:23`：无参 `files=[]` 恒 exit 0）。

⇒ **没有分母的 `findings: 0` 不可解读。** 收割器必须能区分「检查了 812 个单位，0 命中」
和「检查了 0 个单位，0 命中」。这一条是契约里唯一的新增要求，其余全部是既有实践的成文化。

---

## 2. 分两档执行

| 档 | 范围 | 闸侧改动 | 买到什么 |
|---|---|---:|---|
| **v1** | `figma-sync/` 的 11 条在链闸 | **11 条**（见下表；⚠️ 因为 `checkedUnits` 是新增键，**没有一条是零改动**） | 收割器覆盖在链 **11/36**，且**不动一条 `scripts/` 闸** |
| **v2** | `scripts/` 的 25 条在链闸 | 25 条 | 全链可收割 |

**v1 逐条改动量**（`figma-sync/` 在链 11 条）：

| 现状 | 条数 | 要补什么 |
|---|---:|---|
| 已有 `{auditId, checkedAt, totals.findings, findings}` | **3** | 只补 `totals.checkedUnits` |
| 有 `totals` + findings 类键，**无 `auditId`** | **1**（`audit-design-system`） | 补 `auditId` + `checkedUnits` |
| 有结构化发射，但键名各自为政 | **6** | 补 `auditId` + 规范化 `totals{findings, checkedUnits}` + `findings[]` |
| 纯散文，零结构化发射 | **1**（`audit-figma-published-vs-code`） | 从零写一个发射块 |

**v2 是棘轮活，不是一次性任务。** 按闸的价值排序逐条上，每条上完就把分母往前推一格 ——
形态可照 DS 自己 `KNOWN_SILENT` 那种 shrink-only 表：列一张「尚未上契约」的具名清单，只允许缩短。

> ⚠️ **别按目录立规则。** 「`figma-sync/` 那代写得更好」是**归因，不可靠**（报告 §5）——
> 也可能只是那 14 条恰好都是「扫本地文件产结构化报告」这类任务。
> **判据应该是「这条闸的输出会不会被机械消费」，不是「它在哪个目录」。**
> v1 先做 `figma-sync/` 只因为那里改动最小，不因为那里"更该做"。

---

## 3. 收割器（一处改动，这半条原计划成立）

外层读 stdout 末尾的 JSON 块，**不给 38 条各加 JSONL 埋点**。这是原计划里唯一完整成立的部分。

**三条硬要求**：

### 3.1 选择面必须钉 npm key 前缀，⛔ 不能钉「文件是否 `console.log(JSON.stringify)`」

DS 真源现在多了一个 `report:` 前缀、无挂载、形状为
`{generatedFrom, totalGates, testFaceCount, tally, rows}` 的 JSON 发射器
（`scripts/gate-regression-face-inventory.mjs:255-257`）。它**不是闸**、不在链上，
按「文件特征」扫会把它误收进分母。

DS 自己的「这是不是一条闸」判据是**文件名前缀 `audit-`/`check-`/`smoke-` + 挂载声明**
（同文件 `:3-9` 逐字说明它刻意不叫 `audit-*` 就是为了不进那个扫描面）。收割器沿用同一判据。

### 3.2 收割器必须自带交叉校验，不一致时 **fail closed**

至少两处读数互相验：**exit code × `totals.findings`**。
一条闸 `exit 0` 却报了 findings 是合法的（实测 `audit:no-hardcoded-design-tokens` 每次都报却从不拦，
`noMatch` 按设计放行）—— 所以判据不是「不许不一致」，而是**「不一致必须被显式归类，不能静默吞掉」**：

| exit | findings | 判定 |
|---|---|---|
| ≠0 | >0 | 拦下 |
| ≠0 | 0 | ⚠️ **异常**，必须报出来（要么分母塌了，要么阻断理由不在 findings 里） |
| 0 | >0 | 只报不拦（合法，需登记是哪条闸的设计） |
| 0 | 0 & `checkedUnits`>0 | 真通过 |
| 0 | 0 & `checkedUnits`=0 | ⚠️ **假绿**，必须报出来 |

**为什么这条是硬要求**：DS 真源在 2026-08-24 同一个 session 里，靠「两处读数互相矛盾」
救回了 **5 个解析器造出的假读数**（量具 4 个：`scripts/gate-regression-face-inventory.mjs:36-42`、`:94-98`；
注入驱动 1 个：`docs/internal/retrospection/2026-08-24-testing-a-live-gate-without-refactoring-it.md:75-79`
逐字「**救回它的是两处读数互相矛盾** …… 判据永远是「先怀疑解析器，别先记缺陷」」）。
而 lab 侧的收割器**已经栽过同一个坑**（报告 §1：`body.startsWith('{')` 漏判 2 条）。

### 3.3 ⛔ 不得用「输出的第一个字符是不是 `{`」判形态

这正是 lab 自己 `ds-ci-gate-history.mjs:141-149` 的缺陷 —— 一行散文前缀就能让它误判。
契约 v1 规定「JSON 是**最后一条** stdout 输出」，收割器就该**从尾部**解析，不是从头部。

---

## 4. 验收标准

⛔ 每条都要可复核证据（AGENTS §4）。

### 4.1 v1 契约

- [x] `figma-sync/` 11 条在链闸**逐条列出**：`auditId` / `checkedAt` / `totals.findings` / `totals.checkedUnits` / `findings` 五项各是否具备　✅ **证据：`…7b-delta.md:273-285`**
      （**给对照表，不给总数**）
- [x] 每条闸的 `auditId` 与它的 npm key **一一对应且唯一**（跑一次全链，验 `auditId` 集合无重复、无缺失）　✅ **证据：`:287`**
- [x] `findings: 0` 的闸也打出了 `totals`（构造一个干净输入验证，⛔ 不能只验有 findings 的路径）　✅ **证据：`:290`**
- [x] 任一条闸的阻断行为（exit code）**与合并前逐条一致** —— 契约只加输出　✅ **证据：**`metrics/chain-exit-parity.mjs` 2026-09-15 实测：交集 39/39 · exitCode 不一致 0 · 阳性对照报出恰好 1 条****

### 4.2 收割器

- [x] 在 pin 处跑一次全链，收割器**真读出 11 条闸的 `findings` 计数**　✅ **证据：`:310`**
      ⛔ **不是「11 条闸都吐 JSON」** —— 后者现在就已成立（报告 §4.1），但其中 6 条读不出计数（§4.3）
- [x] 选择面 = npm key 前缀。**验证 `report:gate-regression-face` 未被收进分母**（must-not-hit）　✅ **证据：`:312`**
- [x] §3.2 五种 exit×findings 组合**各构造一例**，验收割器分类正确，且两种 ⚠️ 情形**报出来而非吞掉**　✅ **证据：`:313` ⇒ 缺口已转处方并由 DS 落地（pin `7fcca917`）**
- [x] 收割器对「stdout 里有散文前缀 + 末尾 JSON」的闸解析正确　✅ **证据：`:313`**
      （must-hit：`audit:tokenized-diff` 与 `audit:translation-completeness` —— 这两条正是旧判据漏掉的）

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

- **收割器绿 ≠ 契约生效。** 收割器在 0 条闸上跑也会绿。必须给出「本次收割到 N 条闸、其中 M 条读出计数」的实数。
- 只验了 v1 的 11 条就宣布 7b 完成 —— **v2 那 25 条要有一张具名的 shrink-only 清单落地**，
  否则「另立棘轮」只是口头承诺。

---

## 5. 与其他步骤的关系

| 步骤 | 关系 |
|---|---|
| 第 7 步（mockup 合并） | **并行，无依赖。** 但合并后的引擎是**新写的**，应当**直接按 v1 契约发射** —— 这是唯一一处「零额外成本上契约」的机会 |
| 第 9 步（解 fail-fast） | **7b 的下游。** 契约让每条闸的读数可收割，但**曝光分母仍然不等**（N10：失败 run 里靠后的闸压根不跑）。⇒ 契约解决「读得出」，第 9 步解决「可比」。两件事都做完，`rule-hit` 才有意义 |
| 第 12 步（反事实实验） | 候选名单必须等第 9 步修好分母后重取，否则会把「被 fail-fast 遮蔽」误当成「0 命中」 |

## 6. 登记的边界

1. 契约只管 **stdout 的末尾 JSON 块**。stderr、写盘 report、退出码都不在契约范围内（各自另有约定）。
2. **不追踪闸的语义变更。** 一条闸从「只报」改成「会拦」（实测 `audit:no-hardcoded-design-tokens`
   的阻断行为 2026-07-20 才加）在契约里看不出来 ⇒ 收割出的时间序列**跨语义变更不可直接比较**。
3. `checkedUnits` 的「单位」由每条闸自己定义（文件数 / 节点数 / token 数…）。
   ⇒ **跨闸的 `checkedUnits` 不可相加**，它只用于同一条闸的「分母是不是空」判定。
4. 本处方**不改任何闸的判据强度**。契约是输出侧的事，与「这条闸检查得够不够狠」无关。
