# Q5 补完 —— R20 / R21 的 **ESLint 侧**规则（预注册）

> 2026-09-07 落 Q5 时只落了**一半**：`scripts/audit-product-code.mjs` 的 CLI probe。
> 那一半的覆盖边界已在 R20/R21 正文与
> [`2026-09-07-q5-r20-r21-code-mirror-preregistration.md`](2026-09-07-q5-r20-r21-code-mirror-preregistration.md) §2 如实登记。
> 本文件在**动手之前**钉死另一半的判据。
>
> ⛔ 本文件**不是** Q5 那份预注册的续写，它有一个独立的、必须先答的问题：
> **ESLint 侧到底新增了什么牙？** 如果答案是「同一个判据在同一份文本上跑第二遍」，
> 那这一轮的产出就是零 —— 它必须靠**挂载面**读数回答（§0 读数 E），⛔ 不靠「补全了对称性」这种说法。

---

## 0 亲验读数（⛔ 全部本轮单跑、不带管道；这些读数直接改变了落法）

| # | 读数 | 命令 / 出处 |
|---|---|---|
| **A** | `eslint-plugin/rules/` 现有 **6** 条（`no-hardcoded-color` R2 · `no-inline-svg` R1 · `no-hardcoded-spacing` R17 · `no-native-element` R15 · `icon-from-dist` · `no-base-component-import`）⇒ **R20/R21 零 ESLint 规则** | `ls eslint-plugin/rules/` + `eslint-plugin/index.js:25-31` 头注释清单 |
| **B** | 🔴 **parity 的真机制是「判据只有一份、两侧都 import」，⛔ 不是「抄两份 + 测试断言逐字相同」** | `scripts/audit-product-code.mjs:98` 与 `eslint-plugin/rules/no-hardcoded-spacing.js:21` **import 的是同一个** `eslint-plugin/spacing-token-map.js` |
| **C** | R2S 的共享模块方向是 **CLI → eslint-plugin**（判据住在 plugin 里）。而 R20/R21 的判据住在 CLI 里，且依赖该文件里 **8 处共用**的 `lineHasIgnore` / `isCommentOnlyLine`（`audit-product-code.mjs:203-216`）⇒ 反向搬会牵动 R1/R2/R14/R16/R19 五条既有 probe | `grep -n 'function isCommentOnlyLine\|function lineHasIgnore' -A12` + `grep -c lineHasIgnore` |
| **D** | 从 `eslint-plugin/` 相对 import 那个 CLI **在发布包里可解析**：`files` 同时含整个 `eslint-plugin` 目录、`scripts/audit-product-code.mjs`、`scripts/lib/is-cli-entry.mjs`；而该 CLI 只 import **node builtins + `spacing-token-map.js` + `is-cli-entry.mjs`**，三者全在 `files` 里 | `node -e` 读 `package.json.files` + `grep -n '^import' scripts/audit-product-code.mjs` |
| **E** | 🔴 **这才是本轮新增的牙**：ESLint 侧**在 DS 仓有挂载**，CLI 侧四个挂载面全零。`.husky/pre-commit:355-363` 有 eslint dogfood 条件块 —— staged 命中 `src/(canonical\|components)/*.vue` **或 `eslint-plugin/`** 或 `eslint.config.js` ⇒ 跑 `pnpm run lint:ds`；`.github/workflows/ci.yml:64` 的 push 路径过滤含 `eslint-plugin/**` | `grep -n 'eslint\|lint' .husky/pre-commit` + `sed -n 55,75p .github/workflows/ci.yml`；CLI 侧那四个零命中是 Q5 读数 C/D |
| **F** | `pnpm run lint:ds` 基线 **EXIT=0**，零输出（`eslint 'src/canonical/**/*.vue' 'src/components/**/*.vue'`，`package.json:202`） | 单跑，`$?` 直读 |
| **G** | **分母现取**（就在 `lint:ds` 那个扫描面上，**72** 个 `.vue`，用 CLI 导出的纯函数量的）：R20 违例 **0** · R21 违例 **0** · R21 overlay 上下文单元 **2**，且两个单元**全在** `src/components/PopupBox/PopupBox.vue` ⇒ 上闸边际摩擦 = **0** | `node -e` 遍历两个 glob 调 `findR20Violations` / `findR21Violations` / `findR21ContextUnits` |
| **H** | 🔴 **探针实测：ESLint 规则扫全文 + 按任意 `loc` 报，能报进 `.vue` 的 `<style>` 块**（造了个 `.popup-overlay { background: rgba(0,0,0,0.6) }` 的 SFC ⇒ 命中 `line 5 · column 15`） | 一次性 spike（⛔ 未落仓），`Linter.verify` + `vue-eslint-parser` |
| **I** | 现有 6 条规则**全部**是 AST 节点作用域（`Literal` / `TemplateLiteral` / `JSXText` / `VText` / `VElement` / `VAttribute`）⇒ **没有一条扫全文**。且 `no-hardcoded-spacing.js:16` 逐字登记「`<style>` blocks … are covered by the CLI mirror (R17)」 | 逐条读 6 个规则文件 |
| **J** | R20 / R21 的 Acceptance Σ 算法 = **只数顶层 checklist 条，每条按内部 `·` 拆份**（`audit-acceptance-gate-coverage.mjs:211` 逐字「只取顶层，不取缩进的子项」+ 头注释 §76 逐字「`·×3` ⇒ 4 项」）⇒ R20 `2+1+1=4` · R21 `3+1+1=5`，与自陈 `[4]`/`[5]` 对上。⇒ **缩进的续行不改 Σ** | `grep -n 'checklist\|Σ'` + `--list` 印 `(3 条 checklist)` |

### ⚠️ 读数 G 是本轮最该先看的一个 —— 它同时给了「可以上」和「上了也是零信息」

`R20 违例 0 · R21 违例 0` ⇒ 两条规则加成 `error` 也**不会让 `lint:ds` 变红**（摩擦 0，可以上）。
但同一个 0 也意味着：**今日这两条在 DS 仓上是零信息的绿**。
⇒ ⛔ 不许把本轮说成「DS 的 R20/R21 守住了」。真射程与 CLI 那一半**完全同型**：
消费仓（`eslint.config.fragment.mjs`）+ 未来新写的 DS 代码，**前向 fail-closed**。

### 🔴 读数 G + H 合起来还钉住一件事：R21 的 ESLint 侧分母**全部**在 `<style>` 里

那 2 个 overlay 上下文单元是 `PopupBox.vue` 的 `background: var(--mask-overlay)` 与
`backdrop-filter: blur(var(--mask-overlay-blur))` —— 都在 SFC 的 `<style>` 块里。
⇒ **如果 ESLint 规则只看 AST 节点（读数 I 那六条的形态），R21 的 ESLint 侧分母就是 0 ⇒ 恒绿 ⇒ 零信息。**
读数 H 是这条规则能不能成立的**前置**，所以它先于实现被测。

---

## 1 判据**必须扫全文**，⛔ 这不是风格选择，是判据本身逼出来的

读数 I 说本插件的既有形态是「喂节点文本给判据函数、报在节点上」。
**对 R20/R21 照抄那个形态会破坏判据**，两条各有一个具体的坏法：

| 规则 | 只喂节点文本会怎样 | 后果 |
|---|---|---|
| **R20** | 声明可落在**相邻行**（`audit-product-code.mjs:454` 逐字 `near = [lines[i-1], line, lines[i+1]]`）⇒ 只看 `` `#${i+1}` `` 这个 TemplateLiteral 节点，上一行的 `// #N semantic: sequence` **看不见** | **假阳** —— 把规则自己的逃逸口打掉 |
| **R21** | overlay 上下文靠**花括号深度 + 选择器缓冲**（`overlayContextByLine`），选择器可能在声明**上方 7 行** | **漏报** —— 那正是 2026-09-07 F2 造故障当场抓出的缺陷，`audit-product-code.mjs:508-510` 逐字「⛔ 别改回去」 |

⇒ 两条都在 **`Program:exit`** 上扫 `sourceCode.getText()`，按 `loc` 报。

**顺带的边界变化（⚠️ 要如实登记，⛔ 别扩大）**：`no-hardcoded-spacing.js:16` 那条
「`<style>` 留给 CLI 镜像」的边界，在**新这两条上不成立**（读数 H 证明够得着）。
⛔ 但这只对 R20/R21 成立 —— ⛔ 不许读成「plugin 现在都覆盖 `<style>` 了」，
既有 6 条一个字未动，它们的边界照旧。

---

## 2 落法：判据只有一份，ESLint 侧 import 它

```
eslint-plugin/rules/require-hash-n-semantic.js   →  import { findR20Violations, R20_SEMANTIC_VALUES }
eslint-plugin/rules/no-hardcoded-overlay.js      →  import { findR21Violations }
                                                     from '../../scripts/audit-product-code.mjs'
```

- **⛔ 判据算式不抄第二份** —— 闭集 `{sequence, name}`、触发正则、overlay 词族正则、
  花括号深度机制**全部**留在 CLI 那一份里。ESLint 规则只做三件事：取全文、调函数、按 `loc` 报。
- ⇒ parity 是**结构性的**，⛔ 不靠一条「断言两份逐字相同」的测试。
  ⚠️ 方向与 R2S 相反（读数 C），理由是 R20/R21 判据依赖 CLI 里 5 条既有 probe 共用的两个 helper；
  反向搬 = 为零覆盖增益去动 5 条已上闸的 probe。**这个不对称要写进规则头注释**，⛔ 不留给下一轮猜。
- 规则命名照既有族：`no-hardcoded-overlay`（与 `no-hardcoded-color` / `no-hardcoded-spacing` 同形）·
  `require-hash-n-semantic`（断言的是「必须有声明」）。

---

## 3 预注册判据 P1–P8（⛔ 写死在动手之前）

### P1 · 判据只有一份，且**可程序化验证**
- `eslint-plugin/rules/` 两个新文件里 ⛔ 不得出现闭集字面量 / 触发正则 / overlay 词族正则的第二份
- **判据**：一条测试在**同一份文本**上同时跑 ESLint 规则与 CLI 纯函数，断言**逐条对齐**（条数 + 行号 + kind）
- **造故障**：临时往 `R20_SEMANTIC_VALUES` 加一个值 ⇒ ESLint 侧行为**必须同步改变**（证明真是同一份判据，不是巧合一致）

### P2 · 两条各自「必须红」，且**必须在 `<style>` 块里各验一次**
- R20：`` `#${i + 1}` `` 无声明 ⇒ `missing-hash-n-semantic` · 声明值 `auto` ⇒ `bad-hash-n-semantic-value`
- R21：`.popup-overlay { background: rgba(0,0,0,.6) }` ⇒ `raw-rgba-overlay` ·
  `backdrop-filter: blur(8px)` ⇒ `raw-blur-overlay`
- ⚠️ **`<style>` 里那一次是不可省的** —— 读数 G 说 R21 的真分母全在那儿。只在 `.ts` 上验绿 = 没验到牙。

### P3 · 两条各自「不许误报」（阴性对照与 CLI 侧**同源**，逐字取自真实代码）
- R20：`` `#${section.id}` ``（`DocsShell.vue:1255`）· `` `#${routeWithSection}` ``（`:507`）·
  hex `#fff` · 相邻行已声明（上/同/下三个位置）· `AUDIT-IGNORE-R20`
- R21：`var(--mask-overlay)` 正例（`PopupBox.vue:280-281`）· `src/tokens/variables.css` 整份豁免 ·
  注释里的 `rgba(hue,0.18)`（`PillStatus.vue:17`）· 缩略图 `filter: blur(2px)` · 阴影 `box-shadow … rgba`

### P4 · 逃逸口**实测后如实登记**，⛔ 不许含糊写「用 eslint-disable 即可」
- `AUDIT-IGNORE-R20` / `-R21` 由共享判据函数处理 ⇒ **任何位置（含 `<style>`）都该生效**
- `// eslint-disable-next-line …` ⇒ **预期在 `<style>` 里不生效**（vue-eslint-parser 不为 `<style>`
  产出注释 token）。⚠️ **这是预期，不是结论** —— 必须实测，实测到什么就写什么进规则头注释。
  ⛔ 若实测与预期相反，改的是文档，⛔ 不是把测试删掉。

### P5 · 注册面齐全（⛔ 少一处就是「装上了但没接线」）
`index.js`（import + `rules` map + 命名导出 + 头注释清单）· `configs/incremental.js`（`warn`）·
`configs/recommended.js`（`error`）· `configs/strict.js`（继承 recommended，⛔ 无需改）·
`eslint.config.js`（DS 自己 dogfood，读数 F/G 保证不红）· `eslint-plugin/README.md` ·
`docs/CONSUMER_AUDIT_SETUP.md`（**3 处**：§26 规则清单 · §110 样例 · §315 `plugin OK: [...]` 那行字面清单）·
`templates/consumer-product/eslint.config.fragment.mjs:20` 规则清单 ·
`tests/EslintPluginConfigs.test.ts`（incremental + strict 两处断言）

### P6 · 挂载面**真触发**（⛔ 这是本轮唯一新增的牙，必须两侧读数）
- 加规则后 `pnpm run lint:ds` 仍 **EXIT=0**（读数 G ⇒ 零新红）
- **造故障 1**：往 `src/components/PopupBox/PopupBox.vue` 的 `<style>` 注入裸 rgba
  ⇒ `pnpm run lint:ds` **EXIT=1** 且逐字点名 `文件:行:列` + rule id
- **造故障 2**：往 `src/canonical/` 下临时 `.vue` 写 `` `#${i+1}` ``
  ⇒ 同上点名 `require-hash-n-semantic`
- **造故障 3**（⚠️ 上一轮新立的纪律：**造故障之前先量分母**）：
  R21 的分母是 **2 行、1 个文件**（读数 G）。注入**前**先确认这两行的现值，注入时只动**其中一行**，
  ⇒ 若闸仍绿，那是判据没接上；⛔ 别像上一轮 F3 那样删不干净然后把绿读成假信号
- 三次还原后逐字 `git diff` 确认零残留

### P7 · R21 的 ESLint 侧**分母地板**（⛔ 没有它这条规则会静默变恒绿）
- 读数 G：分母 = **2 单元 / 1 文件**。⇒ 谁重构掉 `PopupBox.vue` 的那两行，ESLint 侧分母塌成 0
- **判据**：测试断言 `findR21ContextUnits` 在 `lint:ds` 那个扫描面上 **≥ 1**，失败信息要印出命中文件
- ⚠️ **R20 刻意不加地板** —— 理由与 CLI 侧那份测试第 216-223 行逐字相同：
  DS `src/` 触发命中今日就是 0，加地板 = 假红；写 `≥ 0` = **恒真断言 = 零信息**。⛔ 别加回来

### P8 · 零回归 + 必跑闸
- 必跑：`acceptance-gate-coverage` · `stale-anchors` · `rule-inventory` · `doc-shape` ·
  `gate-mount-declaration`（`rule-load-map` 只在 staged 时触发）
- 全量 `pnpm test` 基线 **3246 passed / 0 failed**，新增测试数算术必须对上
- ⚠️ 改 `code-conventions.md` 的 R20/R21 段时：**只加缩进续行**，⛔ 不加顶层 checklist 条、
  ⛔ 不往顶层条里加 `·` —— 否则 Σ 变（读数 J）⇒ `[4]`/`[5]` 对不上 ⇒ **S4 当场红**
- ⚠️ `templates/consumer-product/.githooks/` 里有**两份** handoff 闸的可执行镜像 + parity 测试。
  本轮不动 handoff 闸 ⇒ 预期不触发；但**全量回归红了就先查这里**（上一轮就是这么发现射程更宽的）

---

## 4 ⛔ 这一批不能说的话

- ⛔ 不许说「R20/R21 现在有闸了 ⇒ DS 已守住」—— 读数 G：两条在 DS 上的违例数都是 **0**，
  今日是**零信息的绿**。真射程是消费仓 + 未来新代码（前向 fail-closed），与 CLI 那一半同型
- ⛔ 不许说「补上了 ESLint 侧 = 补上了对称性」—— 本轮的实质产出是**挂载面**（读数 E）：
  CLI 那一半在 DS 四个挂载面全零，ESLint 这一半吃 `.husky/pre-commit:355-363` 条件块 + CI 路径过滤
- ⛔ 不许说「plugin 现在覆盖 `<style>` 了」—— 只有新这两条覆盖，既有 6 条边界照旧（读数 I）
- ⛔ 不许说「Q5 闭合了」—— R21 的**数值**那一半仍是 **Q11 待 owner 拍**；
  R20 的「声明与实现是否一致」仍无闸（`code-conventions.md:1494` 已登记为独立提案）
- ⛔ 不许说「消费仓真的会拦」—— 读数 D 只证明**可解析**，⛔ 没有任何消费仓的实跑读数

---

## 5 ✅ 兑现记录（DS `5cc457c7`，2026-09-08）

判据全部按 §3 跑完。**造故障七次，每次都先验注入真落盘再读结果** —— F-D 第一遍
`perl` 的正则转义没生效、目标串原样未动，那一遍给出的 `EXIT=0` 是**假绿**
（正是上一轮立的「造故障之前先量分母 / 删不干净的故障会给出绿」）。改用 node 精确
字符串替换 + 「找不到目标串就 exit 9」的前置断言后重做。

| # | 造什么 | 红侧读数 | 误伤 |
|---|---|---|---|
| **F1** | `PopupBox.vue:280` 的 `var(--mask-overlay)` 换成裸 rgba（⚠️ 分母就 2 行，只动其中一行） | `lint:ds` **EXIT=1** · **恰好 1 条** · 逐字 `280:15` + `@ux-team/tvu-design-system/no-hardcoded-overlay` | 0 |
| **F2** | `src/canonical/` 下临时 `.vue` 写 `` `#${i+1}` `` | **EXIT=1** · 逐字 `7:16` + `require-hash-n-semantic`，报文含 owner 那个问法。**相邻行补合法声明 ⇒ 转绿**；改成 `auto` ⇒ **再红**并印出合法值 ⇒ 三态齐 | 0 |
| **F-A** | 把 R20 从「扫全文」退化成「逐行独立扫」（= 同族 6 条那种节点作用域） | **恰好 4 条**转红，全部是跨行声明可见性这一个根因 | 0（另 26 条不动） |
| **F-B** | 把 R21 同样退化成逐行扫（丢掉花括号深度上下文） | **恰好 5 条**转红，全部是 overlay 上下文丢失的直接后果 | 0 |
| **F-C** | 把 R21 报告的 `column` 偏 1 | **恰好 1 条**（parity 那条）转红 ⇒ column 断言不是装饰 | 0 |
| **F-D** | 拆掉 R21 的 token 定义文件整份豁免 | **恰好 1 条**红 —— 正是本轮改写过的那条两侧断言 | 0 |
| **F-E** | 从 `incremental` 摘掉一条规则 | **2 条**红（逐条断言 + 新加的机械断言） | — |

代码类造故障还原后 `cmp EXIT=0` / `git diff` 空 ⇒ 零残留。

**绿侧读数**：`lint:ds` **EXIT=0** · 六条必跑闸全 **EXIT=0** · Σ 未变（`--list` 仍印
`R20 (3 条 checklist)` / `R21 (3 条 checklist)`，自陈 `[4]`/`[5]` 一个字未改）·
全量 `pnpm test` **3277 passed / 0 failed**（基线 3246 + 30 + 1，算术精确对上）。

🔴 **挂载面在真提交上自己证明了一次**：这次提交 staged 命中 `eslint-plugin/`
⇒ `.husky/pre-commit` 的 eslint dogfood 条件块**真触发**，跑的是带新规则的 `lint:ds`
（提交输出里逐字有那两行）。⇒ 读数 E 不再只是 grep 出来的形态，是**观测到的**。

### 🔴 三件预注册没预见到的（都如实登记）

1. **撞出一个恒真断言，并修掉了它。** CLI 侧 `tests/audit-product-code-r20-r21.test.ts`
   那条「token 唯一定义点 ⇒ 整份文件豁免」用的 fixture 是
   `--mask-overlay: rgba(0,0,0,0.6)` —— **自定义属性**，压根不匹配 `R21_RAW_RGBA_RE`
   （它要求 `background(-color):`）也不匹配 `R21_MASK_DECL_RE`
   ⇒ **豁免侧与非豁免侧都是 0 / 0** ⇒ 把豁免整段拆掉它**照样绿** = 零信息。
   ⇒ 改用真会违例的文本 + **两侧**断言（豁免侧 0 · 非豁免侧必须 1）。F-D 证明新形态抓得到。
   ⚠️ 这与本仓已命名多次的病同族，但方向是**反的**：AGENTS 第 25 条讲「恒红 = 零信息」，
   这一条是**恒绿的断言同样零信息** —— 而恒绿更难发现，因为它长得像「一切正常」。
   ⇒ **一条只断言「豁免侧为 0」的用例，永远分辨不了「被豁免了」与「本来就不命中」。**
   凡写豁免/例外类断言，必须同时钉住非豁免侧。

2. **F-A 的红侧条数比我预测的多一倍（预测 2、实际 4）。** 多出的两条是
   「闭集外值 `auto`」与「往闭集 push 一个值」—— 它们**同根因**（都要先看见相邻声明，
   才走得到判值域那一步），不是第二个原因。结论不受影响，但如实记下：
   我当时只想到「假阳」这一种坏法，漏了「判据的后续分支被前置条件挡住」这一种。

3. **P5 把注册面写窄了**：列的是 9 处，实际动了 **11** 处。多出
   `.changeset/`（既有范式：`ed510c6d` 加 `no-hardcoded-spacing` 时就配了 changeset，
   而 Q5 那次只改 CLI **没有**配 ⇒ 加消费者可见的 ESLint 规则必须配）
   与 `eslint-plugin/README.md` 的「判据住在哪」对照表。
   ⛔ `docs/internal/ds-enforcement-scenarios-spec.md` **刻意没动** —— 它的范围是 G1–G4，
   R20/R21 是另一条谱系（Q5），塞进去是范围蔓延。

### ⚠️ 顺带量出一条（不改文件，只登记）

`ed510c6d`（加 `no-hardcoded-spacing` 那次）**只接了 `configs/recommended.js`，
没接 `configs/incremental.js`** —— 那条规则是后来才补进 incremental 的
（`git show --stat` 亲验：那次提交的 10 个文件里没有 `incremental.js`）。
⇒ 「装上了但没接线」在这个插件上**真发生过一次**，且当时没有任何机制会红。
本轮加的机械断言（`plugin.rules` 的键集合必须与 incremental / recommended **完全相同**）
正是补这个洞，F-E 验过它会红。⚠️ `strict` 刻意不进那条断言 —— 它是 recommended 的派生。
