# 闸达成目的的那一刻，为它建的回归面失去了输入

> 2026-08-28 · 执行 ai-ds-lab 两份处方（`./web-components` 类型入口 + Claude Design 用真实组件）时撞到。
> commit `4bbbc467`。⛔ 本篇只记可复用的认知，逐条实测过程在 commit message 与 STATUS-CHANGELOG。

---

## 一 · 主结论：shrink-only 判据成功 ⇒ 以那张表为漂移钉的回归面当场失效

`audit-exports-types-contract` 的 `S4` 是 shrink-only：豁免项一旦不再需要，闸变红并**要求删掉那一行**。
本轮给 `./web-components` 补上随包 `.d.ts` 后，那张表按设计缩到空 —— 这正是该判据存在的目的。

同一刻，`tests/audit-exports-types-contract-cli.test.ts` **12 条红**。它的文件头逐字写着：

> ⚠️ **本文件的绿档是「真 EXEMPTIONS 表」的漂移钉 —— 这是设计意图，不是脆弱**：
> CLI 走 `audit()` 的默认参数 ⇒ 用的是闸里**硬编码**的 `EXEMPTIONS`（不可从 fixture 注入）。
> 于是每个绿档 fixture 的 `package.json` 必须自带 `./web-components` 这个**无 types 的代码出口**。

⇒ 那份回归面把「表里有那一行」当成了**环境常量**，而它其实是**被测判据要消灭的对象**。

**这不是设计错误**，那个漂移钉真的有用（谁动了表，绿档立刻红并说出原因 —— 本轮它确实这么做了）。
真正的缺口是：**它没有为「表被清空」这个成功路径准备说法**。于是「闸干成了它该干的事」与
「回归面红了」外观完全一样，都是 12 条 FAIL。

### 处置（⛔ 三条都不选：不删测试 / 不改成永远绿 / 不往豁免表塞回一行）

- 绿档 fixture 的那个出口改为**自带 types**（与真源现状同形）。
- `C` 组自钉判据从「表恰好一行且 subpath 是 X」翻转为 **`expect(EXEMPTIONS).toHaveLength(0)`**，
  并在注释里写明它现在承担双重职责：表非空 ⇒ ① 本条红并说出原因 ② 下方 skip 的三条要一起恢复。
- 豁免通道与 `S4` 两条 red-path 在 CLI 面**结构上不可达**（`for (const ex of exemptions)` 一次都不进）
  ⇒ 改 `it.skip` 并**保留完整场景描述**，判据逻辑由纯函数那份（可传 `exemptions` 参数）覆盖。

### 可复用的自查

给一条**会消灭某个状态**的判据建回归面时问：**这份 fixture 依赖的东西，是不是正是判据要消灭的？**
是 ⇒ 现在就写下「它被消灭之后这份 fixture 该长什么样」，别等它成功那天再当故障排查。

⚠️ 与 [`2026-08-25-gates-that-cannot-see-what-they-are-asked-to-guard.md`](./2026-08-25-gates-that-cannot-see-what-they-are-asked-to-guard.md)
**方向相反**：那篇是「判据的自变量与选中条件重合 ⇒ 收益恒为零」，本篇是「判据奏效 ⇒ 自己的回归面失去输入」。

---

## 二 · 同一个包名的两份物理副本，module augmentation 只打中其中一份

删掉 wrapper 里 56 处 `@ts-expect-error` 后，`tsc -p react-pilot/tsconfig.json` 报 **60 条 TS2339**，
而随包 `.d.ts` 里的 `declare module 'react' { namespace JSX { … } }` 明明写对了，`--listFiles` 也证明
那份 d.ts **确实被加载进了 program**。

**根因（实测，非推断）**：本仓有两份物理不同的 `@types/react` —— 根 `node_modules/.pnpm/@types+react@19.2.17/…`
与 `react-pilot/node_modules/.pnpm/@types+react@19.2.17/…`。**同版本、不同 store 路径**。
d.ts 住在 `src/web-components/`，从那里解析 `'react'` 得到根那份；wrappers 编译时加载的是 react-pilot 那份
⇒ augmentation 打在了一个 wrappers 永远看不见的模块上。

**定位方法（三次对照，每次只动一个变量）**：

| 探针 | 结果 | 排除了什么 |
|---|---|---|
| `declare global` vs `declare module 'react'`，都放 `react-pilot/src/` | global 失效 / module 生效 | 确认写法本身没错（与处方的五版本矩阵吻合） |
| `extends TvuIntrinsicElements` vs 直接成员，都放 `react-pilot/src/` | **两种都生效** | 排除「`extends` 在 augmentation 里不传导」 |
| 直接成员形态，放**那份 d.ts 自己里** | **失效** | ⇒ 唯一剩下的变量是**文件位置** |

**修法**：另产一份 `react-pilot/src/wrappers/jsx.d.ts`，`import type` 那份 d.ts 的 tag map 再 re-apply。
tag map 仍只声明一次，两处不可能漂。⚠️ **consumer 侧没有这个问题**（他们只装一份 `@types/react`）
⇒ 这是纯粹的仓库内部管线问题，别写进面向 consumer 的文档。

### 可复用的自查

module augmentation 不生效、而文件确实在 program 里时，**先问「这个文件解析到的那个包，和被测代码解析到的是同一个物理文件吗」**，
再怀疑写法。monorepo / 嵌套 `node_modules` / pnpm store 下，同版本 ≠ 同模块。

---

## 三 · 本轮两次抓到「判据器械自己坏了」

两次都是**因为打印了实际输出**才发现，只看退出码都会读成结论：

1. `echo "EXIT=${PIPESTATUS[0]}"` 在本机 shell 下取到**空值** ⇒ 三条后台检查全部报「exit code 0」，
   而其中一条真实 `EXIT=2`。（触发器 I §第四维「换个 shell 方言就换个答案」的又一实例。）
   ⇒ 改成 `cmd > file 2>&1; echo "EXIT=$?"`。
2. consumer 跨版本矩阵首轮 **5×4 格全 FAIL**，看着像随包类型完全没生效；实际是
   `npm install --no-save @types/react@X` 每次按 `package.json` 重建 `node_modules`，
   把先装的 `typescript` **和手动解包的被测包**一起删了 ⇒ `exit 127`。
   ⇒ 每个版本独立目录 + **先 install 后解包** + 每格跑前一条 `preflight`（tsc 在 / 被测包在 / @types 在），
   preflight 不过就明说「这一格的结果没有意义」，不让它进结论。

⇒ **全 FAIL 与全 PASS 一样需要先证明器械是活的。**

---

## 四 · 执行外部处方时，「处方没覆盖的坑」占了大头

两份处方本身写得很实（含五版本矩阵、致败探针、封路理由），但落地时新撞到 **4 处**，
其中 2 处会直接让 master 变红：两份 `@types/react`（§二）· kebab attribute 未声明 ·
豁免表清空掀翻 harness（§一）· 生成的 markdown 表里 TS 联合的 `|` 未转义。

⇒ **外部处方的价值在「方向 + 已排除的错路」，不在「照做就能过」。**
接手时按触发器 S 核前提是必要的，但不充分 —— 它核的是**处方写下的那些数还对不对**，
而这 4 处都在处方的视野之外（lab 逐字声明了只读边界、未在 DS 上跑过 build 与 typecheck）。
