# 体检报告另外三面「可复现」实施计划

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** 让体检报告 `reports/2026-08-28-design-system-review.{md,html}` 的**另外三面**（组件数 /
入口类型声明 / 变量占比）像 acceptance 面一样，变成可复跑、带内建控制、读数落盘的量具产物。

**Architecture:** 三个纯 Node 零依赖脚本落在 `metrics/`，各自独立自包含（沿用 `metrics/` 现有惯例：
每支量具单文件、不共享 lib）。全部只读 pin 住的 worktree **P4 = `19e55102`**（体检报告的取数时点），
JSON 落 `subjects/tvu-ds/inventory/`，Markdown 落 `reports/`，最后把读数回填进体检报告并重建 `reports/index.html`。

**Tech Stack:** Node ≥18 内置模块（`node:fs` / `node:path` / `node:child_process`）。零第三方依赖。

**Spec:** 无独立 spec 文档。需求来自 owner 2026-08-28 逐字交接：「补复验体检报告另外三面：组件数 /
入口类型声明 / 变量占比 —— 本次只在 P4 上复现了 acceptance 面，其余三面读数目前仍不可复现。」
被复现对象是 [`reports/2026-08-28-design-system-review.md`](../reports/2026-08-28-design-system-review.md) 的
第一节（能做什么）· 第二节（视觉统不统一）· 第三节（接得顺不顺）。

---

## 🔴 「可复现」的定义（⛔ 不是「凑出同一个数」）

**本计划的成功判据是「读数有可复跑的判据 + 有内建控制证明判据有判别力」，⛔ 不是「跑出来等于报告里那个数」。**

⇒ 三种结局都算 Task 完成，但**必须分开登记**：

| 结局 | 处理 |
|---|---|
| 新量具读数 **= 报告读数** | 报告该行标注量具名 + 命令，状态升为「可复现」 |
| 新量具读数 **≠ 报告读数** | 🔴 **订正报告**，并逐条写明差额来源（口径差 / 报告当时读错 / 量具读错）。⛔ 不许调量具去迁就报告 |
| 读数**真源定位不到** | 🔴 如实登记为「不可复现」并从报告中删除或降级为「未量」。⛔ 不许凑一个看起来对的数 |

⚠️ 已知有**两处读数来源尚未定位**（起手勘察结论，见下方「已完成的前提验证」）：
「**53 个图标**」与「**53 个使用示例**」。Task 3 必须先定位真源，定位不到就走第三种结局。

---

## Global Constraints

以下逐条来自 `AGENTS.md` 与 `docs/round2-status.md`，**每个 Task 的要求隐含包含本节**：

1. **只读 pin，⛔ 不读 DS 工作区**（`AGENTS.md` §1）。`--pin` 必须指向 `~/.ai-ds-lab/pins/tvu-ds-<sha>`；
   worktree **dirty ⇒ fail closed**（`process.exit(1)`，⛔ 不许 warn 后继续）。
   ⚠️ DS 侧有并行 session 在写真源，HEAD 漂得很快（本轮实测 `cb2e6576` → `80f56a17` → `3db72145`）。
2. **先去注释再切**（`AGENTS.md` §3.3）。凡文本切片判据，先剥注释再匹配。
   这个仓库为此栽过三次：DS 侧 2026-08-12 `max-width: 1100px`（19 错报 22）；lab 侧 2026-08-28
   两次（N76 漏 `GATE_DIR` 目录面 · 风格轴「17 个组件有 variant prop」实为 2 个）。
3. **取退出码 ⛔ 别接管道**（`AGENTS.md` §3.9）。zsh 下 `${PIPESTATUS[0]}` 是**空的**，
   接了会读到管道末端命令的码。用 `$pipestatus[1]`，或干脆不接管道。已连踩两个 session。
4. **凡量具输出「空 / 零 / 无变化」，先证它有判别力再报结论**（`round2-status.md` §30.5.2）。
   🔴 **本计划命中这条的正是 Task 2**：「组件样式里写死颜色 **0** 次 / 设计变量占比 **100%**」
   —— 「0」与「量具坏了」在输出上一模一样，**必须做注入式阴性对照**。
5. **内建控制钉具名事实**（`AGENTS.md` §3.2）。控制要钉「这份文件/这个入口应该是什么形态」，
   ⛔ 不钉「解析器有没有抛异常」（后者在形态换代时恒真 = 零信息，第 25 条）。
   每支量具至少 1 条 `must-hit` + 1 条 `must-not-hit`，`--self-check` 全绿才允许出报告。
6. **覆盖边界显式登记**（`AGENTS.md` §3.4）。每支量具头部注释写「本器不判什么」，
   **那是边界，⛔ 不是 TODO**。
7. **报占比必须同时给【绝对数】与【分母】两栏**（`round2-status.md` 第 26 条推论三）。
   ⇒ Task 2 不许只报「100%」，必须并列 `549 / 549` 与分母定义。
8. **纯 Node 零依赖**（D4 / AI 中立）。⛔ 不装包、⛔ 不跑 DS 的 build、⛔ 不新跑 run。
9. **0 有四种读法**（第 27 条推论三）：①真零命中 ②文件在该 sha 上不存在 ③判据写错 ④面没对齐。
   任何读数为 0 时，量具必须在 JSON 里带 `zeroKind` 字段说明是哪一种。

---

## 已完成的前提验证（本计划据此成立，⛔ 不必重做）

起手在 P4 pin 上已实测确认（`git -C ~/.ai-ds-lab/pins/tvu-ds-19e55102 rev-parse --short=8 HEAD` = `19e55102`）：

| 读数 | 报告值 | P4 实测 | 判据 |
|---|---:|---:|---|
| 组件数 | 28 | **28** | `src/components/` 下目录数 |
| 检查程序数 | 69 | **69** | `scripts/audit-*.mjs` 文件数 |
| 文档篇数 | 288 | **288** | `docs/**/*.md` |
| 自动化命令数 | 158 | **158** | `package.json` `scripts` 键数 |
| 对外入口数 | 15 | **15** | `package.json` `exports` 键数 |
| 变更记录行数 | 687 | **687** | `wc -l CHANGELOG.md` |
| 无障碍测试配置 | 2 | **2** | `playwright.a11y*.config.ts` |
| 运行时依赖 | 零 | **`dependencies: {}`**，`peerDependencies: {vue: ^3.5.0}` | `package.json` |
| Web Components 入口有无 `types` | 无 | **无 `types` 键** | `exports["./web-components"]` 只有 `import`/`require` |
| `./eslint-plugin` 入口形态 | — | **裸字符串**（N70：结构上不受闸检） | `exports["./eslint-plugin"] = "./eslint-plugin/index.js"` |

**⛔ 两处未定位（Task 3 必须先解决）**：
- 「**53 个图标**」：`src/icons/generated/` 是 18 个分类文件 × 每份 2 个 `export const` = **36**，
  与 53 不符 ⇒ 53 的真源另在别处（疑为 manifest / catalog，未定位）。
- 「**53 个使用示例**」：`playground/**/*.vue` = **49**，`playground/docs/` = 14 项 ⇒ 均不等于 53。

⚠️ 这两处**不是 bug，是「报告读数当前不可复现」的具体形态** —— 正是本计划要处理的对象。

---

## File Structure

| 文件 | 责任 | Task |
|---|---|:-:|
| `metrics/entry-types-coverage.mjs` | 面 3：`exports` 逐入口 × 代码/非代码 × 有无类型声明 | 1 |
| `metrics/token-hardcode-ratio.mjs` | 面 2：组件样式/模板的设计变量引用数 vs 写死色值数 + 注释块色值↔token 配对核对 | 2 |
| `metrics/deliverable-inventory.mjs` | 面 1：组件/图标/WC/主题/语言包/示例/文档/命令/检查程序/依赖/变更记录 + 三框架对位 + 命名差异 | 3 |
| `subjects/tvu-ds/inventory/entry-types-19e55102.json` | 面 3 读数落盘 | 1 |
| `subjects/tvu-ds/inventory/token-hardcode-19e55102.json` | 面 2 读数落盘 | 2 |
| `subjects/tvu-ds/inventory/deliverable-inventory-19e55102.json` | 面 1 读数落盘 | 3 |
| `reports/entry-types-19e55102.md` | 面 3 逐入口清单 | 1 |
| `reports/token-hardcode-19e55102.md` | 面 2 逐条清单（含注释块明细） | 2 |
| `reports/deliverable-inventory-19e55102.md` | 面 1 逐项清单 | 3 |
| `reports/2026-08-28-design-system-review.md` | 🔴 **回填**：三面每行标注量具 + 命令；不符处订正 | 4 |
| `reports/2026-08-28-design-system-review.html` | 🔴 **回填**：与 md 同步 | 4 |
| `reports/index.html` | 重建（`build-report-index.mjs`，收进 3 份新报告） | 4 |
| `docs/round2-status.md` | 登记 N79（⛔ 只登记不开格，§30.4 冻结仍生效） | 4 |

**依赖顺序**：Task 1 → Task 2 → Task 3 → Task 4。Task 1–3 之间**无代码依赖**（各自自包含），
但按此序做可让后一支复用前一支验证过的 CLI 骨架与控制写法。Task 4 必须**最后**做（要三份读数都在）。

---

## Task 1: `metrics/entry-types-coverage.mjs` —— 面 3「入口类型声明」

**为什么**：这是体检报告**唯一的 🔴 头条结论**（「官方给 React 的路径缺类型声明」），也是 §30.2 第 3 条
「立刻只做 1 件」的对象。而它当前的读数「15 入口 / 10 代码入口 / 1 个缺类型」**没有可复跑判据**。
⚠️ 且 N70 已判出一处口径差（`./eslint-plugin` 是裸字符串 ⇒ 闸结构上看不见），量具必须把这条**显式分档**。

**Files:**
- Create: `metrics/entry-types-coverage.mjs`
- Create: `subjects/tvu-ds/inventory/entry-types-19e55102.json`（脚本产出）
- Create: `reports/entry-types-19e55102.md`（脚本产出）

**Interfaces:**
- Consumes: 无（首个 Task）。只读 `<pin>/package.json` 与 `<pin>/eslint-plugin/`。
- Produces:
  - CLI：`node metrics/entry-types-coverage.mjs --pin <worktree> [--self-check] [--out <json>] [--md <md>]`
  - JSON 顶层键（后续 Task 4 逐字引用这些名字）：
    `auditId: 'entry-types-coverage'` · `subjectSha` · `pin` · `dirty` · `checkedAt` ·
    `counts: { total, codeEntry, nonCodeEntry, typed, untyped, opaque }` ·
    `rows: Array<{ key, spec, shape, isCodeEntry, typesField, dtsOnDisk, verdict, zeroKind }>` ·
    `controls: Array<{ id, pass, detail }>`
  - `verdict` 取值（闭集，⛔ 后续 Task 不许新造）：
    `'typed'` · `'untyped'` · `'not-applicable'` · `'opaque-shape'`

- [ ] **Step 1: 写内建控制（先写控制，再写主流程）**

在脚本里定义 4 条控制。⚠️ 钉的是**具名事实**，⛔ 不是「解析没抛异常」：

```js
// 内建控制（AGENTS §3.2）—— 钉具名事实
const CONTROLS = [
  {
    id: 'must-hit-0/exports-count',
    // P4 上 exports 恰好 15 个键。少了 ⇒ 读错了文件；多了 ⇒ pin 漂了
    run: (ctx) => ({ pass: ctx.rows.length === 15, detail: `exports keys = ${ctx.rows.length} (expect 15)` }),
  },
  {
    id: 'must-hit-1/webcomponents-untyped',
    // 报告的头条结论：./web-components 无 types。它若变成 typed，说明 DS 已修 ⇒ 报告结论要改，不是量具坏了
    run: (ctx) => {
      const r = ctx.rows.find((x) => x.key === './web-components')
      return { pass: !!r && r.verdict === 'untyped', detail: `./web-components verdict = ${r ? r.verdict : 'MISSING'}` }
    },
  },
  {
    id: 'must-hit-2/main-entry-typed',
    // 阴性对照的另一半：主入口必须判成 typed。若连它都判 untyped ⇒ 判据整片失效
    run: (ctx) => {
      const r = ctx.rows.find((x) => x.key === '.')
      return { pass: !!r && r.verdict === 'typed', detail: `"." verdict = ${r ? r.verdict : 'MISSING'}` }
    },
  },
  {
    id: 'must-not-hit-0/svg-entry-never-untyped',
    // ./icons/svg/* 是图形文件入口，⛔ 不许被算成「缺类型的代码入口」（那会把 1 虚报成 2）
    run: (ctx) => {
      const r = ctx.rows.find((x) => x.key === './icons/svg/*')
      return { pass: !!r && r.verdict === 'not-applicable', detail: `./icons/svg/* verdict = ${r ? r.verdict : 'MISSING'}` }
    },
  },
]
```

- [ ] **Step 2: 跑控制，确认它们**在主流程写好前**就是红的**

Run: `node metrics/entry-types-coverage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check`
Expected: **FAIL**（`exit=1`），因为 `ctx.rows` 还是空数组，4 条控制应报 `pass: false`。

⚠️ **这一步不许跳过**。控制在实现前必须是红的 —— 否则无法区分「控制通过」与「控制恒真」（第 25 条）。

- [ ] **Step 3: 写入口分类判据**

```js
// 入口形态三分（⛔ 别只分「有没有 types」两档 —— N70 那条口径差就藏在第三档）
//   ① object 形态：{ types?, import?, require? }  ⇒ 可判 typed/untyped
//   ② 裸字符串形态："./eslint-plugin/index.js"    ⇒ 结构上不带 types 位 ⇒ opaque-shape
//   ③ 非代码目标：.css / .json / .svg             ⇒ not-applicable
const NON_CODE_EXT = new Set(['.css', '.json', '.svg'])

function classify(key, spec, pin) {
  // 非代码：看解析目标的扩展名（字符串形态或 object 的 import/default）
  const target = typeof spec === 'string' ? spec : (spec.import || spec.default || spec.require || '')
  const ext = path.extname(String(target).replace(/\*.*$/, '')) || path.extname(String(target))
  if (NON_CODE_EXT.has(ext)) {
    return { shape: typeof spec === 'string' ? 'string' : 'object', isCodeEntry: false, typesField: null, verdict: 'not-applicable' }
  }
  if (typeof spec === 'string') {
    // 裸字符串：结构上没有 types 位。⛔ 不许直接判 untyped —— 它与「object 形态但漏了 types」不是一回事
    return { shape: 'string', isCodeEntry: true, typesField: null, verdict: 'opaque-shape' }
  }
  return {
    shape: 'object',
    isCodeEntry: true,
    typesField: spec.types || null,
    verdict: spec.types ? 'typed' : 'untyped',
  }
}
```

- [ ] **Step 4: 补 `dtsOnDisk` —— 「声明了 types」≠「文件真在」**

`types` 字段指向 `./dist/**`，而 pin 里**没有 build 产物**。⇒ 不许把「文件不在」读成「缺类型」（第 27 条推论三第 ② 种 0）。

```js
// dtsOnDisk 三态：true / false / 'unbuilt'
//   ⛔ 'unbuilt' ≠ 'false'。dist/ 未 build 时全部入口都读不到 .d.ts，
//      那是【面没对齐】(第 27 条推论三第 ④ 种 0)，⛔ 不是「DS 缺类型」
function dtsOnDisk(pin, typesField) {
  if (!typesField) return null
  const distRoot = path.join(pin, 'dist')
  if (!fs.existsSync(distRoot)) return 'unbuilt'
  return fs.existsSync(path.join(pin, typesField))
}
```

同时把 N70 那条实测做成一行读数（`./eslint-plugin` 目录里的真实 `.d.ts` 数）：

```js
// N70 实测：该目录 0 个 .d.ts。即「同样无类型，但闸看不见」——⛔ 不并入那 8 条盲区，口径不同
function eslintPluginDtsCount(pin) {
  const d = path.join(pin, 'eslint-plugin')
  if (!fs.existsSync(d)) return { count: 0, zeroKind: 'file-absent' }
  const n = fs.readdirSync(d, { recursive: true }).filter((f) => String(f).endsWith('.d.ts')).length
  return { count: n, zeroKind: n === 0 ? 'true-zero' : null }
}
```

- [ ] **Step 5: 跑控制，确认 4 条全绿**

Run: `node metrics/entry-types-coverage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check`
Expected: **PASS**（`exit=0`），4 条控制全 `pass: true`，且 `must-hit-0` detail 逐字打印 `exports keys = 15 (expect 15)`。

⛔ 取退出码写 `node ... --self-check; echo "exit=$?"` —— **不接管道**（Global Constraint 3）。

- [ ] **Step 6: 出报告，并与体检报告第三节逐行对账**

Run:
```bash
node metrics/entry-types-coverage.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 \
  --out subjects/tvu-ds/inventory/entry-types-19e55102.json \
  --md  reports/entry-types-19e55102.md
echo "exit=$?"
```

Expected: `exit=0`；报告里必须能逐行对上体检报告第三节那张表的 4 行（有类型 6 项 / WC 无 / ESLint 2 项 / SVG 不适用）。
🔴 **对不上就按「可复现的定义」第 2 种结局办**：订正报告，写明差额来源。
⚠️ 已预判一处会不一致：体检报告把 ESLint 那 2 个入口写成「🔴 无」，本量具判 `opaque-shape` ——
这**不是量具错**，是报告把两种不同的「无」并成了一栏（N70 的口径差）。按第 2 种结局订正。

- [ ] **Step 7: Commit**

```bash
git add metrics/entry-types-coverage.mjs subjects/tvu-ds/inventory/entry-types-19e55102.json reports/entry-types-19e55102.md
git commit -m "feat(metrics): 入口类型声明量具 —— 面 3 读数可复现 + 分出第三档 opaque-shape"
```

---

## Task 2: `metrics/token-hardcode-ratio.mjs` —— 面 2「变量占比」

**为什么**：这是体检报告**唯一的满分结论**（「100% 走设计变量，零处写死颜色」），也因此是**最脆的一条**：
🔴 「0 处写死」与「量具坏了」在输出上一模一样（§30.5.2）。而报告自己就记着这一面**当场栽过一次**
（初读「73 处写死颜色」实为注释里的色值）—— 即这一面的判据**必须先剥注释**（Global Constraint 2）。

**Files:**
- Create: `metrics/token-hardcode-ratio.mjs`
- Create: `subjects/tvu-ds/inventory/token-hardcode-19e55102.json`（脚本产出）
- Create: `reports/token-hardcode-19e55102.md`（脚本产出）

**Interfaces:**
- Consumes: Task 1 验证过的 CLI 骨架写法（`--pin` / `--self-check` / `--out` / `--md` / dirty fail-closed）。⛔ 不 import Task 1 的代码（各量具自包含）。
- Produces:
  - CLI：`node metrics/token-hardcode-ratio.mjs --pin <worktree> [--self-check] [--out <json>] [--md <md>] [--inject-probe]`
  - JSON 顶层键：
    `auditId: 'token-hardcode-ratio'` · `subjectSha` · `pin` · `dirty` · `checkedAt` ·
    `counts: { styleVarRefs, styleHardcoded, templateHardcoded, ratioNumerator, ratioDenominator, ratioPct }` ·
    `comments: { colorLiterals, blocks, blocksWithToken, blocksWithoutToken, pairsExtracted, pairsMatched, pairsDrifted }` ·
    `rows: Array<{ file, line, kind, raw, token, matched }>` · `controls`
  - `kind` 取值（闭集）：`'var-ref'` · `'style-hardcode'` · `'template-hardcode'` · `'comment-literal'`

- [ ] **Step 1: 写「剥注释但保留行号」的切片器**

⚠️ 三种注释形态都要剥，且**行号必须守恒**（用空串替换，⛔ 不删行）：

```js
// AGENTS §3.3：凡文本切片判据，先去注释再切 —— 保留行号（⛔ 不删行）
// 三种形态：CSS /* */（跨行）· HTML <!-- -->（跨行）· JS/TS //（行尾）
// 返回 { stripped, commentSpans } —— commentSpans 单独留着，面 2 第 ② 层要数注释里的色值
function stripComments(text) {
  const lines = text.split('\n')
  const kept = lines.slice()
  const spans = []
  let inBlock = null // 'css' | 'html'
  for (let i = 0; i < lines.length; i++) {
    let s = lines[i]
    let out = ''
    let j = 0
    while (j < s.length) {
      if (inBlock === 'css') {
        const e = s.indexOf('*/', j)
        if (e === -1) { spans.push({ line: i + 1, text: s.slice(j) }); j = s.length }
        else { spans.push({ line: i + 1, text: s.slice(j, e) }); j = e + 2; inBlock = null }
        continue
      }
      if (inBlock === 'html') {
        const e = s.indexOf('-->', j)
        if (e === -1) { spans.push({ line: i + 1, text: s.slice(j) }); j = s.length }
        else { spans.push({ line: i + 1, text: s.slice(j, e) }); j = e + 3; inBlock = null }
        continue
      }
      const c = s.indexOf('/*', j), h = s.indexOf('<!--', j), l = s.indexOf('//', j)
      const cands = [[c, 'css'], [h, 'html'], [l, 'line']].filter(([p]) => p !== -1).sort((a, b) => a[0] - b[0])
      if (!cands.length) { out += s.slice(j); break }
      const [p, kind] = cands[0]
      out += s.slice(j, p)
      if (kind === 'line') { spans.push({ line: i + 1, text: s.slice(p + 2) }); break }
      inBlock = kind
      j = p + (kind === 'css' ? 2 : 4)
    }
    kept[i] = out
  }
  return { stripped: kept.join('\n'), commentSpans: spans }
}
```

- [ ] **Step 2: 写控制 —— 🔴 含本计划最关键的一条 `must-not-hit`**

```js
const CONTROLS = [
  {
    id: 'must-hit-0/var-refs-nonzero',
    // 549 次 var(--…) 引用。若为 0 ⇒ 判据整片失效（不是「DS 不用变量」）
    run: (ctx) => ({ pass: ctx.counts.styleVarRefs > 0, detail: `styleVarRefs = ${ctx.counts.styleVarRefs}` }),
  },
  {
    id: 'must-not-hit-0/comments-never-counted',
    // 🔴 报告当场栽过的那一条：注释里的 73 处色值 ⛔ 不许算进 styleHardcoded
    // 判据：注释里数到色值 > 0，而 styleHardcoded 仍为 0 ⇒ 剥注释真的生效了
    run: (ctx) => ({
      pass: ctx.comments.colorLiterals > 0 && ctx.counts.styleHardcoded === 0,
      detail: `comment literals = ${ctx.comments.colorLiterals}, style hardcoded = ${ctx.counts.styleHardcoded}`,
    }),
  },
  {
    id: 'must-not-hit-1/font-variant-not-a-color',
    // 风格轴那次的同型坑：CSS 属性名里含关键词 ⛔ 不许被当成命中（font-variant-numeric 不是 variant prop）
    // 这里的对应形态：`--` 开头的自定义属性【定义】行 ⛔ 不许算成【引用】
    run: (ctx) => ({
      pass: !ctx.rows.some((r) => r.kind === 'var-ref' && /^\s*--[\w-]+\s*:/.test(r.raw)),
      detail: 'no custom-property definition line counted as a var() reference',
    }),
  },
  {
    id: 'must-hit-1/injection-probe-flips',
    // 🔴 §30.5.2 那条纪律的落地：--inject-probe 往一份组件样式里注入一处硬编码色值，
    //    styleHardcoded 必须从 0 变成 >0。不变 ⇒ 「0」是量具坏了，不是真的零
    run: (ctx) => ({ pass: ctx.probe == null || ctx.probe.flipped === true, detail: JSON.stringify(ctx.probe) }),
  },
]
```

- [ ] **Step 3: 跑控制，确认实现前是红的**

Run: `node metrics/token-hardcode-ratio.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check; echo "exit=$?"`
Expected: `exit=1`，`must-hit-0` 报 `styleVarRefs = 0`。

- [ ] **Step 4: 写主判据（样式面 / 模板面 分开数）**

```js
// 色值形态四种全收（⛔ 只收 #rrggbb 会漏掉一半）
const RE_COLOR = /(#[0-9a-fA-F]{3,8}\b|\brgba?\s*\(|\bhsla?\s*\(|\b(?:aliceblue|antiquewhite|aqua|black|white|red|green|blue|gray|grey)\b)/g
const RE_VAR_REF = /\bvar\(\s*--[\w-]+/g

// 分母定义（Global Constraint 7：⛔ 不许只报 100%）
//   分子 = 样式面里「取颜色」的位置中走 var() 的次数
//   分母 = 分子 + styleHardcoded
// ⇒ 100% 的含义是「取颜色的位置全部走变量」，⛔ 不是「所有 CSS 都走变量」
```

⚠️ 只扫 `<pin>/src/components/**/*.vue`：`<style>` 块进样式面，`<template>` 块进模板面。
⛔ 不扫 `playground/` / `react-pilot/` / `docs/` —— 那是**边界**（写进脚本头部 §3.4 段），不是 TODO。

- [ ] **Step 5: 写 `--inject-probe` 阴性对照（在 pin 的【副本】上做，⛔ 不写 pin）**

```js
// 🔴 §30.5.2：凡输出「零」，先证判别力
// 做法：把一份组件的 .vue 读进内存，往 <style> 里插一行 `color: #ff00aa;`，
//       用同一套判据重新数一遍 —— styleHardcoded 必须 0 → 1
// ⛔ 绝不写回 pin（AGENTS §1 只读）。全程在内存里做，pin 一个字节不动
function injectProbe(pin, files) {
  const victim = files[0]
  const orig = fs.readFileSync(victim, 'utf8')
  const hacked = orig.replace(/<style([^>]*)>/, '<style$1>\n.tvu-probe { color: #ff00aa; }')
  const before = measureOne(orig).styleHardcoded
  const after = measureOne(hacked).styleHardcoded
  return { victim: path.relative(pin, victim), before, after, flipped: before === 0 && after === 1 }
}
```

- [ ] **Step 6: 跑阴性对照 + 全控制**

Run:
```bash
node metrics/token-hardcode-ratio.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --inject-probe --self-check
echo "exit=$?"
git -C ~/.ai-ds-lab/pins/tvu-ds-19e55102 status --short
```
Expected:
- `exit=0`，4 条控制全绿
- `probe` 打印 `{ before: 0, after: 1, flipped: true }`
- 🔴 `git status --short` **必须无输出** —— 证明 pin 一个字节没被改（AGENTS §1）

- [ ] **Step 7: 补第 ② 层 —— 注释块「色值 ↔ token」配对核对**

体检报告第二节那张表（12 对提取 / 12 对得上 / 0 漂）要能复跑：

```js
// 从 commentSpans 里提取「色值 ↔ token」配对，四种书写形态全扫：
//   ① `--icon-default (dark #9e9e9e / light #595959)`
//   ② `#9e9e9e = --icon-default`
//   ③ `token=--icon-default → #9e9e9e`
//   ④ `Color Type/Icon/Default` + 同段出现 var(--icon-default)
// 再与 src/tokens/variables.css 里 token 的真实取值逐条比对（明/暗两套都要比）
// ⚠️ 「0 漂」也是一个零输出 ⇒ 必须能造一处假漂验出 pairsDrifted 从 0 变 1
```

Run: `node metrics/token-hardcode-ratio.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check; echo "exit=$?"`
Expected: `exit=0`，`comments.pairsExtracted` / `pairsMatched` / `pairsDrifted` 三个数打印出来。
🔴 与报告的 `12 / 12 / 0` 不符 ⇒ 走第 2 种结局（订正报告 + 写明差额来源）。

- [ ] **Step 8: 出报告 + Commit**

```bash
node metrics/token-hardcode-ratio.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 \
  --out subjects/tvu-ds/inventory/token-hardcode-19e55102.json \
  --md  reports/token-hardcode-19e55102.md
echo "exit=$?"
git add metrics/token-hardcode-ratio.mjs subjects/tvu-ds/inventory/token-hardcode-19e55102.json reports/token-hardcode-19e55102.md
git commit -m "feat(metrics): 变量占比量具 —— 面 2 读数可复现 + 注入式阴性对照证明「0 写死」有判别力"
```

---

## Task 3: `metrics/deliverable-inventory.mjs` —— 面 1「组件数」

**为什么**：面 1 是体检报告的**开篇**（读者第一眼看到的那张表），却是三面里读数**最多**、来源**最杂**的一面
（12 个数字散在 `src/` / `docs/` / `package.json` / 根目录配置）。且已确知**两处读数定位不到**（53 图标 / 53 示例）。

**Files:**
- Create: `metrics/deliverable-inventory.mjs`
- Create: `subjects/tvu-ds/inventory/deliverable-inventory-19e55102.json`（脚本产出）
- Create: `reports/deliverable-inventory-19e55102.md`（脚本产出）

**Interfaces:**
- Consumes: Task 1 / Task 2 验证过的 CLI 骨架 + `stripComments` 写法（⛔ 复制而非 import，各量具自包含）。
- Produces:
  - CLI：`node metrics/deliverable-inventory.mjs --pin <worktree> [--self-check] [--out <json>] [--md <md>]`
  - JSON 顶层键：
    `auditId: 'deliverable-inventory'` · `subjectSha` · `pin` · `dirty` · `checkedAt` ·
    `items: Array<{ key, value, source, method, zeroKind, reproducible }>` ·
    `frameworkParity: { vue, react, webComponents, nameMismatches: Array<{ vue, react }> }` · `controls`
  - `reproducible` 取值（闭集）：`'exact'`（与报告一致）· `'corrected'`（不一致，已定案）· `'unlocated'`（真源定位不到）

- [ ] **Step 1: 写 12 项读数的判据表（判据写在数据里，⛔ 不散在代码里）**

```js
// 每项一行：key / 报告值 / 判据函数。判据集中在一张表里 ⇒ 报告回填时可逐行对账
const ITEMS = [
  { key: 'components',      reported: 28,  method: 'src/components/ 下的目录数' },
  { key: 'icons',           reported: 53,  method: '🔴 真源未定位 —— 本 Task 必须先定位' },
  { key: 'webComponents',   reported: 3,   method: 'src/web-components/components.config.ts 注册项数' },
  { key: 'themes',          reported: 2,   method: 'src/tokens/variables.css 里的主题选择器数（明/暗）' },
  { key: 'locales',         reported: 1,   method: 'src/locale/ 下的语言包数' },
  { key: 'examples',        reported: 53,  method: '🔴 真源未定位 —— 本 Task 必须先定位' },
  { key: 'docs',            reported: 288, method: 'docs/**/*.md 文件数' },
  { key: 'commands',        reported: 158, method: 'package.json scripts 键数' },
  { key: 'auditScripts',    reported: 69,  method: 'scripts/audit-*.mjs 文件数' },
  { key: 'runtimeDeps',     reported: 0,   method: 'package.json dependencies 键数' },
  { key: 'changelogLines',  reported: 687, method: 'CHANGELOG.md 行数' },
  { key: 'a11yConfigs',     reported: 2,   method: 'playwright.a11y*.config.ts 文件数' },
]
```

- [ ] **Step 2: 定位「53 图标」的真源（⛔ 定位不到就登记 `unlocated`，不许凑）**

按顺序试三个候选，**每个候选把实测数打印出来**：

```bash
P=~/.ai-ds-lab/pins/tvu-ds-19e55102
# 候选 ①：icons manifest 的条目数
node -e 'const m=require("fs").readFileSync(process.env.P+"/src/icons/manifest.ts","utf8"); console.log("manifest export const =", (m.match(/^export const /gm)||[]).length)' P=$P
# 候选 ②：catalog 里登记的图标名总数
node -e 'const fs=require("fs"),p=process.env.P+"/src/icons/catalog"; for(const f of fs.readdirSync(p)) console.log(f)' P=$P
# 候选 ③：generated/ 里真正的图标名（18 分类 × N 个名字，⚠️ 不是 18×2 个 export const）
node -e 'const fs=require("fs"),path=require("path"),d=process.env.P+"/src/icons/generated"; let t=0; for(const f of fs.readdirSync(d)){if(!f.endsWith(".ts"))continue; const s=fs.readFileSync(path.join(d,f),"utf8"); const n=(s.match(/^\s*'"'"'[\w-]+'"'"'\s*:/gm)||[]).length; t+=n; console.log(f,n)} console.log("total icon names =",t)' P=$P
echo "exit=$?"
```

Expected: 三个候选里**有一个** = 53 ⇒ 该候选即真源，写进 `method`，`reproducible: 'exact'`。
🔴 **三个都不是 53** ⇒ `reproducible: 'unlocated'`，Task 4 把报告那行改成实测值 + 标注判据，
并在 status 的 N79 里如实登记「报告原值 53 来源不可复现」。⛔ 不许挑一个最接近的当答案。

- [ ] **Step 3: 定位「53 使用示例」的真源（同上纪律）**

```bash
P=~/.ai-ds-lab/pins/tvu-ds-19e55102
find "$P/playground" -name '*.vue' | wc -l          # 已实测 = 49
ls "$P/playground/docs" | wc -l                      # 已实测 = 14
find "$P/playground/public" -name '*.html' | wc -l
node -e 'const fs=require("fs");const t=fs.readFileSync(process.env.P+"/playground/docs/changelog-nav.ts","utf8");console.log("nav entries =",(t.match(/path:/g)||[]).length)' P=$P
echo "exit=$?"
```

Expected: 同 Step 2 —— 命中 53 则记 `exact`，否则 `unlocated`。

- [ ] **Step 4: 三框架对位 + 命名差异（🔴 必须不区分大小写比第二遍）**

体检报告第三节「摩擦 2」那条（`Checkbox` vs `CheckBox`）是**撞出来的**，量具要把它变成常规读数：

```js
// ⚠️ 报告自己记着：最初按名字比对得出「React 少一个组件」，改成不区分大小写才发现它一直都在
// ⇒ 判据必须【两遍都跑】，并把差额登记为 nameMismatches（⛔ 不是「缺失」）
function parity(pin) {
  const vue = fs.readdirSync(path.join(pin, 'src/components'))
  const react = fs.readdirSync(path.join(pin, 'react-pilot/src/wrappers')).map((f) => f.replace(/\.tsx?$/, ''))
  const exact = vue.filter((v) => react.includes(v))
  const ci = vue.filter((v) => react.some((r) => r.toLowerCase() === v.toLowerCase()))
  const mismatches = ci.filter((v) => !exact.includes(v))
    .map((v) => ({ vue: v, react: react.find((r) => r.toLowerCase() === v.toLowerCase()) }))
  return { vue: vue.length, react: react.length, exactMatch: exact.length, ciMatch: ci.length, nameMismatches: mismatches }
}
```

- [ ] **Step 5: 写控制**

```js
const CONTROLS = [
  { id: 'must-hit-0/components-28', run: (c) => ({ pass: c.item('components') === 28, detail: `components = ${c.item('components')}` }) },
  { id: 'must-hit-1/zero-runtime-deps', run: (c) => ({ pass: c.item('runtimeDeps') === 0 && c.peerDeps.vue, detail: `deps=0, peer.vue=${c.peerDeps.vue}` }) },
  // 🔴 「零依赖」也是一个零输出 ⇒ 必须证明判据能看见非零：peerDependencies 必须读到 vue
  { id: 'must-hit-2/name-mismatch-found', run: (c) => ({ pass: c.parity.nameMismatches.length === 1, detail: JSON.stringify(c.parity.nameMismatches) }) },
  // ⛔ 不许把大小写差异算成「React 少一个组件」——报告当场栽过这一条
  { id: 'must-not-hit-0/ci-never-reported-as-missing', run: (c) => ({ pass: c.parity.ciMatch === 28, detail: `ciMatch = ${c.parity.ciMatch} (expect 28)` }) },
  // 未定位项必须显式带 unlocated，⛔ 不许静默填 0（第 27 条推论三）
  { id: 'must-not-hit-1/no-silent-zero', run: (c) => ({ pass: c.items.every((i) => i.value !== 0 || i.zeroKind != null), detail: 'every zero carries zeroKind' }) },
]
```

- [ ] **Step 6: 跑控制 + 出报告**

```bash
node metrics/deliverable-inventory.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 --self-check; echo "exit=$?"
node metrics/deliverable-inventory.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102 \
  --out subjects/tvu-ds/inventory/deliverable-inventory-19e55102.json \
  --md  reports/deliverable-inventory-19e55102.md
echo "exit=$?"
```
Expected: 两次都 `exit=0`；报告里 12 项每项带 `source` / `method` / `reproducible` 三栏。

- [ ] **Step 7: Commit**

```bash
git add metrics/deliverable-inventory.mjs subjects/tvu-ds/inventory/deliverable-inventory-19e55102.json reports/deliverable-inventory-19e55102.md
git commit -m "feat(metrics): 交付物清单量具 —— 面 1 十二项读数可复现 + 两处真源定位结论"
```

---

## Task 4: 回填体检报告 + 重建索引 + 登记 status

**为什么**：三支量具跑完只是「读数可复现」，但**体检报告本身还没变** —— 读者拿到的仍是不可复现的版本。
⚠️ 这一步是 `round2-status.md` §28.9 那条纪律（「判出对方已做/已裁 ⇒ 当场回改，⛔ 不只写报告」）**用在 lab 自己身上**。

**Files:**
- Modify: `reports/2026-08-28-design-system-review.md`（第一/二/三节每个读数加量具标注；不符处订正）
- Modify: `reports/2026-08-28-design-system-review.html`（与 md 同步）
- Modify: `reports/index.html`（跑 `build-report-index.mjs` 重建）
- Modify: `docs/round2-status.md`（在 §30.5 表格末尾追加 **N79** 一行）

**Interfaces:**
- Consumes: Task 1–3 产出的三份 JSON（逐字用它们的 `counts` / `items` / `comments` 键名）。
- Produces: 无下游 Task。

- [ ] **Step 1: 在报告里给每个读数加「判据出处」**

给第一/二/三节每张表加一列或一行脚注，格式统一：

```markdown
> **判据**：`node metrics/deliverable-inventory.mjs --pin ~/.ai-ds-lab/pins/tvu-ds-19e55102`
> · 读数落盘 `subjects/tvu-ds/inventory/deliverable-inventory-19e55102.json`
```

- [ ] **Step 2: 逐条订正不符项（⛔ 不许静默改数）**

每处订正写成一个可见的块，**保留原值**：

```markdown
> ⚠️ **订正（2026-08-28 复验）**：原写「53 个图标」，实测 `<新值>`（判据：`<method>`）。
> 差额来源：`<口径差 / 报告当时读错 / 真源定位不到>`。
```

- [ ] **Step 3: html 与 md 同步，并程序化核对两者读数一致**

⛔ 手工同步会漂。用一次性核对把两边的数字提出来比：

```bash
node -e '
const fs=require("fs");
const md=fs.readFileSync("reports/2026-08-28-design-system-review.md","utf8");
const html=fs.readFileSync("reports/2026-08-28-design-system-review.html","utf8");
const nums=(s)=>[...s.matchAll(/\*\*(\d[\d,]*)\*\*|<strong>(\d[\d,]*)<\/strong>/g)].map(m=>m[1]||m[2]);
const a=nums(md), b=nums(html);
const miss=a.filter(x=>!b.includes(x));
console.log("md nums:",a.length,"html nums:",b.length,"in md but not html:",miss.join(",")||"(none)");
process.exit(miss.length?1:0)'
echo "exit=$?"
```
Expected: `exit=0`，`in md but not html: (none)`。
⛔ 取退出码**不接管道**（Global Constraint 3）。

- [ ] **Step 4: 重建报告索引**

```bash
node metrics/build-report-index.mjs; echo "exit=$?"
```
Expected: `exit=0`，`reports/index.html` 里能找到三份新报告的条目。

- [ ] **Step 5: 登记 N79（⛔ 只登记不开格）**

在 `docs/round2-status.md` §30.5 那张表末尾追加一行。⚠️ 措辞必须写明**这不是第 10 格**，
§30.4 冻结仍生效 —— 本 Task 做的是「让已有报告可复现」，**评审面未扩大**。

- [ ] **Step 6: Commit**

```bash
git add reports/2026-08-28-design-system-review.md reports/2026-08-28-design-system-review.html reports/index.html docs/round2-status.md
git commit -m "docs(report): 体检报告三面读数回填量具判据 + 逐条订正不符项（N79）"
```

---

## Self-Review 记录

**1. Spec 覆盖**：owner 交接的三面 —— 组件数（Task 3）· 入口类型声明（Task 1）· 变量占比（Task 2），
外加「回填到报告」（Task 4，交接未明说但 §28.9 纪律要求）。✅ 无遗漏。

**2. Placeholder 扫描**：无 TBD / TODO / 「适当处理」。两处「真源未定位」是**已知事实 + 已给三条候选命令 +
已给定位不到时的处理规则**，⛔ 不是 placeholder。

**3. 类型一致性**：三支量具的 `auditId` / `counts` / `controls` 键名在 Interfaces 块里逐字给出，
Task 4 引用的正是这些名字。`verdict` / `kind` / `reproducible` / `zeroKind` 四个枚举都标了闭集。

**4. 与既有量具的口径隔离**：三支新量具**不复用** `acceptance-coverage-dump.mjs` 的代码，
也不共享 `counts` 语义 —— 面不同，⛔ 不可混读（沿用 §28.13 那条「口径不同不可混读」）。

---

## 覆盖边界（⛔ 这是边界，不是 TODO）

1. **只在 P4 = `19e55102` 上复现。** ⛔ 不做多时点 diff —— 那需要在 P5/P6 上也跑，属另一件事。
2. **不判「读数对不对产品价值有意义」。** 例如「288 篇文档」是多是少，本计划不评。
3. **不 build。** 凡依赖 `dist/**` 的判据一律走 `'unbuilt'` 三态，⛔ 不为取数去跑 DS 的 build（§1）。
4. **不碰 acceptance 面。** 那一面已由 `acceptance-coverage-dump.mjs` 覆盖（N73），本计划不重做。
5. **不改 DS 一个字节。** 阴性对照全程在内存里做，跑完必须 `git -C <pin> status --short` 无输出。
