# R13 commit-message affordance trace 闸 — 实现计划

> ✅ **已全部执行并落地 2026-08-24**（计划写于 08-21）。**两处设计在执行中被实测推翻，本文档下方 Task 1/2 的代码块是原设计、已过时** —— 落地实况的真源是 `scripts/audit-commit-affordance-trace.mjs` 的头注释：
> 1. **触发面**：原设计只有「`Icon*` 标识符」一条臂，实测**在本仓结构上近乎空扫**（全仓只有 barrel `src/index.ts` 引用那些标识符；本仓一律按 `<Icon name="action/close" />` **字符串注册表名**消费图标）⇒ 改为双臂，主臂是注册表名 + 属性赋值位置匹配。
> 2. **排除面**：原设计 3 类，实测要 4 类 —— 漏了 **tracked 构建产物 `react-pilot/dist`**（内联全部 643 个图标名 ⇒ 每次 docs 站重建恒红）与**公共 barrel `src/index.ts`**。
> 3. 顺带补掉一个原计划未预料的结构缺口：`audit:gate-ci-parity` 的 A 侧只读 `.husky/pre-commit` ⇒ 新钩子上的闸对元闸天然隐形（Task 5 Step 3 已含，但当时以为只是「小扩面」，实际是 [[INFRA-F116]] 永久逃逸换钩子名重演）。
>
> ⇒ **接手时别照下方代码块实现，去读闸头注释**；本文档保留原样作为「计划 vs 实况」的对照留痕。

> **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:** 给 `code-conventions.md` §R13 §Acceptance 里「commit message 必含 3 行 affordance trace」这半条规则上一个真会跑的闸 —— 新建 `.husky/commit-msg`（L4）+ 它自己的 CI 可达面（L5，扫 push/PR 区间的 `git log`），并把新钩子家族纳入元闸 `audit:gate-ci-parity` 的判定面。

**Architecture:** 三层。① **判据核心** = 一个纯函数模块化的 `.mjs`：从 R13 正文那张同义词矩阵**现读**关键词（⛔ 不硬编码，meta-rules 反模式 #1）→ 与 `src/icons/index.ts` 的导出名求交得到 **affordance 图标闭集** → 在 commit 的 diff **新增行**里找这些标识符 → 命中即要求 commit message 含 3 行 trace。② **L4** = `.husky/commit-msg`（`pre-commit` 结构上做不到：它自己的注释逐字写着「跑在 `-m` 的 message 落地之前」）。③ **L5** = `pr-checks.yml` 两条触发各自的区间解析（⛔ `push:master` 上不许用 `origin/master...HEAD`，那形态恒空 —— meta-rules 触发器 R 实证案例 #4）。

**Tech Stack:** Node ESM `.mjs`（无依赖）· husky v9 shim（`.husky/_` 已含 `commit-msg`）· vitest · Gitea Actions（`fetch-depth: 0` 已就位）

**Spec:** 无独立 spec 文档 —— 判据真源是两处**规则原文**：
- `docs/internal/code-conventions.md` §R13 §Acceptance（第 1210-1225 行）：3 行格式 + probe `git log --grep "Affordance:"`
- `docs/internal/backlog.md` §INFRA-F123 ①（第 70 行）：本轮要关的就是它逐字写的「R13 的 commit-message 半边」

## Global Constraints

- **⛔ 不发明新产物格式**（F123 entry §共同结构逐字）：锚点与 3 行短语必须与已上闸的 handoff 半边**逐字一致** —— `Affordance:` / `Vocabulary scan:` / `Chosen:`，N/A 档写 `Affordance: N/A — <理由>`（真源 `scripts/audit-handoff-deliverable-sections.mjs` 的 `RULES[].phrase` / `traceLines` / `naRe`）
- **⛔ 关键词矩阵不许抄进脚本**（meta-rules 反模式 #1）：从 `code-conventions.md` §R13 Step 2 那张表现读；解析出 0 行 ⇒ **fail closed**（exit 1），不是「今天没关键词」
- **⛔ 无参调用不许 exit 0**（F116 第 4 批实证：`files=[]` 恒绿的 step 比没有 step 更糟）
- **闸脚本头注释前 45 行内必须有挂载层自声明**（`audit:gate-mount-declaration` 只读头 45 行 —— 上一轮已因此被拒过一次）
- **changeset 判据**：新脚本 `scripts/audit-commit-affordance-trace.mjs` **不在** `package.json` `files[]` 白名单内（白名单里 `scripts/` 只有 `audit-mockup-*` / `audit-product-code` / `audit-stale-anchors` / `validate-upstream-gate` / `consumer-postinstall` / `setup-consumer.sh` / 4 个 `scripts/lib/*`）⇒ **本轮不写 changeset**。⛔ 若中途决定镜像进 `templates/`（在 files[] 内）⇒ 当场必须补 changeset
- **⛔ 不改 `core.hooksPath`**：worktree 与主仓共享 `.git/config`，改它会影响正在并行工作的 session。本 worktree 的 `core.hooksPath` 是**主仓绝对路径** ⇒ git 驱动的钩子跑的是**主仓那份**，L4 路径的验证必须用直接调用 + `git -c core.hooksPath=<worktree>/.husky/_` 单次覆盖

---

## File Structure

| 文件 | 责任 |
|---|---|
| `scripts/audit-commit-affordance-trace.mjs` **(create)** | 判据核心 + 三种入口（`--message-file` 钩子档 / `--range` CI 档 / `--commit` 单 commit 档）。导出纯函数供单测直接调 |
| `.husky/commit-msg` **(create)** | L4 挂载点。唯一职责：把 `$1` 转给上面那个脚本 |
| `.gitea/workflows/pr-checks.yml` **(modify)** | L5 挂载点。按事件类型解析区间（PR = base..head · push = before..after） |
| `package.json` **(modify)** | 加 npm script `audit:commit-affordance-trace` |
| `scripts/audit-gate-ci-parity.mjs` **(modify)** | A 侧从「只读 `.husky/pre-commit`」扩成 hook 家族（`pre-commit` + `commit-msg`），否则新钩子上的闸对元闸**天然隐形** |
| `tests/audit-commit-affordance-trace.test.ts` **(create)** | 判据单测（含单侧故障注入） |
| `tests/audit-gate-ci-parity.test.ts` **(modify)** | 跟随 `computeParity` 签名变化 + 新增 hook 家族的三条断言 |
| `docs/internal/backlog.md` **(modify)** | F123 ① 收紧：剩 R0 / M48（⛔ entry 不删档） |
| `docs/STATUS.md` · `docs/internal/STATUS-CHANGELOG.md` · tracker | wrap-up 同步 |

---

### Task 1: 判据核心 —— 从规则原文现读关键词 + 图标闭集

**Files:**
- Create: `scripts/audit-commit-affordance-trace.mjs`
- Test: `tests/audit-commit-affordance-trace.test.ts`

**Interfaces:**
- Produces:
  - `parseAffordanceKeywords(conventionsMd: string): Map<string, string[]>` — key = affordance category（如 `directional-vertical`），value = 该行「必扫关键词」列里反引号包住的词，全部小写。解析不到任何一行 ⇒ `throw`
  - `collectAffordanceIconIds(iconIndexTs: string, keywords: Map<string,string[]>): Set<string>` — `export {…}` 块里形如 `IconXxx` 的标识符 ∩ 名字（转小写后）含任一关键词者。闭集为空 ⇒ `throw`

- [ ] **Step 1: 写失败的测试**

```ts
import { describe, it, expect } from 'vitest'
import { parseAffordanceKeywords, collectAffordanceIconIds } from '../scripts/audit-commit-affordance-trace.mjs'

const R13_TABLE = [
  '| Affordance | 必扫关键词 | typical 命中 |',
  '|---|---|---|',
  '| `directional-vertical` | `up`, `down`, `chevron`, `arrow` | `Arrow/Down` |',
  '| `dismissive` | `close`, `cancel`, `x` | `Edit/Close` |',
].join('\n')

describe('parseAffordanceKeywords', () => {
  it('从 R13 Step 2 表里读出 category → 关键词', () => {
    const m = parseAffordanceKeywords(R13_TABLE)
    expect(m.get('directional-vertical')).toEqual(['up', 'down', 'chevron', 'arrow'])
    expect(m.get('dismissive')).toEqual(['close', 'cancel', 'x'])
  })

  it('解析面塌了要 throw，不是返回空表', () => {
    expect(() => parseAffordanceKeywords('# 没有那张表')).toThrow(/关键词矩阵/)
  })
})

describe('collectAffordanceIconIds', () => {
  const kw = parseAffordanceKeywords(R13_TABLE)

  it('只收名字命中关键词的导出标识符', () => {
    const idx = `export {\n  IconActionChevronLeft,\n  IconClose,\n  IconActionBill,\n  actionIcons,\n}`
    const ids = collectAffordanceIconIds(idx, kw)
    expect(ids.has('IconActionChevronLeft')).toBe(true)
    expect(ids.has('IconClose')).toBe(true)
    expect(ids.has('IconActionBill')).toBe(false)   // 不含任何关键词
    expect(ids.has('actionIcons')).toBe(false)      // 不是 Icon* 标识符
  })

  it('闭集为空要 throw（图标真源塌了 ≠ 今天没 affordance 图标）', () => {
    expect(() => collectAffordanceIconIds('export {\n  actionIcons,\n}', kw)).toThrow(/图标闭集/)
  })
})
```

- [ ] **Step 2: 跑测试确认它红**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts`
Expected: FAIL —— `Failed to resolve import`（脚本还不存在）

- [ ] **Step 3: 写最小实现（含前 45 行内的挂载层自声明）**

头注释必须在**前 45 行**内写完「挂载层」段（`audit:gate-mount-declaration` 只读头 45 行），把长篇 WHY 放到 45 行之后。

```js
// audit-commit-affordance-trace.mjs — R13 §Acceptance 的 commit-message 半边：
// 引入 affordance 类图标的 commit，其 message 必含 3 行 trace。
//
// 挂载层（本注释是挂载真源）：
//   · L4 = .husky/commit-msg（无条件跑；判据自己按 diff 决定触发与否）
//   · L5 = .gitea/workflows/pr-checks.yml（pull_request 与 push:master 两条触发各自解析区间）
//   ⛔ 刻意**不**进 prepublishOnly：它守 commit 历史的形态，与发布产物无关。
//   ⛔ 结构上不能挂 pre-commit：`.husky/pre-commit` 自己的注释逐字写着它跑在
//      `git -m` 的 message 落地之前（2026-05-27 retro）⇒ 那条路径读不到 message。
//
// 判据真源是**规则原文**，不是本文件：
//   · 3 行短语 / N/A 档格式 = code-conventions.md §R13 §Acceptance（与已上闸的 handoff
//     半边 audit-handoff-deliverable-sections.mjs 逐字同一套，⛔ 别在这里发明第二套）
//   · 同义词矩阵 = code-conventions.md §R13 Step 2 那张表，**每次运行现读**
//     （meta-rules 反模式 #1：项目级规则不进脚本）
//
// Usage:
//   node scripts/audit-commit-affordance-trace.mjs --message-file <path>   # 钩子档
//   node scripts/audit-commit-affordance-trace.mjs --range <A>..<B>        # CI 档
//   node scripts/audit-commit-affordance-trace.mjs --commit <sha>          # 单 commit
// Exit: 0 = 无触发 或 trace 齐全 · 1 = 触发但 trace 缺行 / 任一输入面塌了 / 无参
import { readFileSync, existsSync } from 'node:fs'
import { execFileSync } from 'node:child_process'
import { fileURLToPath } from 'node:url'
import { dirname, join, resolve } from 'node:path'

const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')
const CONVENTIONS = join(REPO_ROOT, 'docs', 'internal', 'code-conventions.md')
const ICON_INDEX = join(REPO_ROOT, 'src', 'icons', 'index.ts')

/** R13 Step 2 表：`| \`<category>\` | \`kw\`, \`kw\` | … |` */
export function parseAffordanceKeywords(md) {
  const out = new Map()
  for (const line of String(md || '').split('\n')) {
    const m = /^\|\s*`([a-z][a-z-]*)`\s*\|([^|]*)\|/.exec(line)
    if (!m) continue
    const kws = [...m[2].matchAll(/`([^`]+)`/g)].map((k) => k[1].trim().toLowerCase()).filter(Boolean)
    if (kws.length) out.set(m[1], kws)
  }
  if (out.size === 0) {
    throw new Error('R13 Step 2 的关键词矩阵解析为 0 行 —— 判定面塌了（不是「今天没关键词」）')
  }
  return out
}

/** `src/icons/index.ts` 的 `IconXxx` 导出名 ∩ 关键词。 */
export function collectAffordanceIconIds(indexTs, keywords) {
  const all = [...new Set([...String(indexTs || '').matchAll(/\bIcon[A-Z][A-Za-z0-9]*/g)].map((m) => m[0]))]
  const flat = [...new Set([...keywords.values()].flat())]
  const hit = new Set(all.filter((id) => {
    const lower = id.slice(4).toLowerCase()
    return flat.some((kw) => lower.includes(kw))
  }))
  if (hit.size === 0) {
    throw new Error('affordance 图标闭集为空 —— src/icons/index.ts 或关键词矩阵塌了')
  }
  return hit
}
```

- [ ] **Step 4: 跑测试确认全绿**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts`
Expected: PASS 4/4

- [ ] **Step 5: 用真活源跑一次，把闭集规模打出来（防「正则对着假样本绿」）**

Run:
```bash
node -e "
const {parseAffordanceKeywords,collectAffordanceIconIds}=await import('./scripts/audit-commit-affordance-trace.mjs');
const fs=await import('node:fs');
const kw=parseAffordanceKeywords(fs.readFileSync('docs/internal/code-conventions.md','utf8'));
const ids=collectAffordanceIconIds(fs.readFileSync('src/icons/index.ts','utf8'),kw);
console.log('categories',kw.size,'affordance icons',ids.size);
console.log([...ids].slice(0,8).join(' '));
" 2>&1
```
Expected: `categories` ≥ 10（R13 表 10 行）· `affordance icons` 是个**三位数以内的非零数**，样例里能看到 `IconClose` / `Icon*Chevron*` 之类。⛔ 若得 0 或全库 —— 停下改判据，别继续。

- [ ] **Step 6: Commit**

```bash
git add scripts/audit-commit-affordance-trace.mjs tests/audit-commit-affordance-trace.test.ts
git commit -F <msgfile> -- scripts/audit-commit-affordance-trace.mjs tests/audit-commit-affordance-trace.test.ts
git reset -- scripts/audit-commit-affordance-trace.mjs tests/audit-commit-affordance-trace.test.ts
```

---

### Task 2: 触发面 —— diff 新增行里的图标命中（含四条排除）

**Files:**
- Modify: `scripts/audit-commit-affordance-trace.mjs`
- Test: `tests/audit-commit-affordance-trace.test.ts`

**Interfaces:**
- Produces:
  - `IN_SCOPE_RE: RegExp` — 受管路径（`src/` · `playground/` · `react-pilot/` 下的 `.vue/.ts/.tsx/.js/.jsx/.mjs/.html`）
  - `EXCLUDE_RE: RegExp` — `src/icons/**`（生成的 registry 逐字含每个图标名）· `**/*.test.*` · `tests/`
  - `addedIconHits(diffText: string, iconIds: Set<string>): Array<{file: string, id: string}>`

- [ ] **Step 1: 写失败的测试**

```ts
import { addedIconHits } from '../scripts/audit-commit-affordance-trace.mjs'

const ids = new Set(['IconClose', 'IconActionChevronLeft'])

function diff(file: string, added: string) {
  return `diff --git a/${file} b/${file}\n--- a/${file}\n+++ b/${file}\n@@ -1 +1,2 @@\n+${added}\n`
}

describe('addedIconHits', () => {
  it('受管路径的新增行命中即触发', () => {
    expect(addedIconHits(diff('src/canonical/Foo.vue', 'import { IconClose } from "../icons"'), ids))
      .toEqual([{ file: 'src/canonical/Foo.vue', id: 'IconClose' }])
  })

  it('删除行不触发', () => {
    const d = `diff --git a/src/a.vue b/src/a.vue\n--- a/src/a.vue\n+++ b/src/a.vue\n@@ -1 +1 @@\n-IconClose\n`
    expect(addedIconHits(d, ids)).toEqual([])
  })

  it('src/icons/** 排除 —— 否则每次图标 sync 恒红', () => {
    expect(addedIconHits(diff('src/icons/generated/action.ts', 'export const IconClose = {}'), ids)).toEqual([])
  })

  it('测试文件排除', () => {
    expect(addedIconHits(diff('tests/Foo.test.ts', 'IconClose'), ids)).toEqual([])
  })

  it('非受管扩展名排除（.md 里提到图标名不算写代码）', () => {
    expect(addedIconHits(diff('docs/internal/x.md', 'IconClose'), ids)).toEqual([])
  })

  it('子串不算命中（IconCloseable 不是 IconClose）', () => {
    expect(addedIconHits(diff('src/a.vue', 'IconCloseable'), ids)).toEqual([])
  })
})
```

- [ ] **Step 2: 跑测试确认它红**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts -t addedIconHits`
Expected: FAIL —— `addedIconHits is not a function`

- [ ] **Step 3: 实现**

```js
/** 受管路径：产品 UI 代码。⛔ 别放宽到 .md —— 文档里提到图标名不是「写了个图标」。 */
export const IN_SCOPE_RE = /^(src|playground|react-pilot)\/.*\.(vue|tsx?|jsx?|mjs|html)$/
/** 排除：生成的图标 registry（逐字含每个名字）· 测试。 */
export const EXCLUDE_RE = /^(src\/icons\/|tests\/)|\.(test|spec)\.[cm]?[jt]sx?$/

export function addedIconHits(diffText, iconIds) {
  const hits = []
  let file = null
  for (const line of String(diffText || '').split('\n')) {
    const fm = /^\+\+\+ b\/(.+)$/.exec(line)
    if (fm) { file = fm[1]; continue }
    if (!file || !line.startsWith('+') || line.startsWith('+++')) continue
    if (!IN_SCOPE_RE.test(file) || EXCLUDE_RE.test(file)) continue
    for (const id of iconIds) {
      if (new RegExp(`\\b${id}\\b`).test(line) && !hits.some((h) => h.file === file && h.id === id)) {
        hits.push({ file, id })
      }
    }
  }
  return hits
}
```

- [ ] **Step 4: 跑测试确认全绿**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts`
Expected: PASS 10/10

- [ ] **Step 5: Commit**（同 Task 1 的 `-F` + `-- <显式路径>` + `git reset --` 形态）

---

### Task 3: message 判据 + 三种入口 + fail-closed 出口顺序

**Files:**
- Modify: `scripts/audit-commit-affordance-trace.mjs`
- Test: `tests/audit-commit-affordance-trace.test.ts`

**Interfaces:**
- Produces:
  - `checkMessage(msg: string): {anchor: boolean, na: boolean, naRationale: boolean, missing: string[]}`
  - `evaluateCommit({diffText, message, iconIds}): {triggered: boolean, hits: Array, ok: boolean, reasons: string[]}`
  - `isMergeMessage` 不做 —— merge 判定走 parent 数（`--range`/`--commit` 档）与 `MERGE_HEAD` 存在性（钩子档）

- [ ] **Step 1: 写失败的测试**

```ts
import { checkMessage, evaluateCommit } from '../scripts/audit-commit-affordance-trace.mjs'

const ids = new Set(['IconClose'])
const trig = `diff --git a/src/a.vue b/src/a.vue\n+++ b/src/a.vue\n@@\n+IconClose\n`
const FULL = [
  'feat: add close button',
  '',
  'Affordance: dismissive (intent: close modal)',
  'Vocabulary scan: [Edit/Close (dist), Edit/Delete (dist)]',
  'Chosen: Edit/Close — same geometry',
].join('\n')

describe('checkMessage', () => {
  it('3 行齐全', () => expect(checkMessage(FULL).missing).toEqual([]))
  it('缺 Vocabulary scan 单侧点名', () =>
    expect(checkMessage(FULL.replace(/Vocabulary scan:.*\n/, '')).missing).toEqual(['Vocabulary scan:']))
  it('缺 Chosen 单侧点名', () =>
    expect(checkMessage(FULL.replace(/Chosen:.*/, '')).missing).toEqual(['Chosen:']))
  it('连锚点都没有', () => expect(checkMessage('feat: x').anchor).toBe(false))
  it('N/A 带理由算齐', () => {
    const r = checkMessage('chore: move file\n\nAffordance: N/A — 纯搬移，未新增 affordance 元素')
    expect(r.na).toBe(true); expect(r.naRationale).toBe(true); expect(r.missing).toEqual([])
  })
  it('N/A 不带理由不算', () => {
    const r = checkMessage('chore: x\n\nAffordance: N/A')
    expect(r.na).toBe(true); expect(r.naRationale).toBe(false)
  })
})

describe('evaluateCommit', () => {
  it('未触发 ⇒ 不要求 trace（⛔ 不许恒红）', () => {
    const r = evaluateCommit({ diffText: '+++ b/README.md\n+hello\n', message: 'docs: x', iconIds: ids })
    expect(r.triggered).toBe(false); expect(r.ok).toBe(true)
  })
  it('触发 + 齐全 ⇒ 过', () => expect(evaluateCommit({ diffText: trig, message: FULL, iconIds: ids }).ok).toBe(true))
  it('触发 + 缺行 ⇒ 不过且点名', () => {
    const r = evaluateCommit({ diffText: trig, message: 'feat: x', iconIds: ids })
    expect(r.ok).toBe(false)
    expect(r.reasons.join(' ')).toMatch(/Affordance:/)
  })
})
```

- [ ] **Step 2: 跑测试确认它红**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts -t checkMessage`
Expected: FAIL —— `checkMessage is not a function`

- [ ] **Step 3: 实现判据 + `main()`（出口顺序：先 fail-closed，再 skip，最后判据）**

```js
/** 与 handoff 半边逐字同一套短语。⛔ 别在这里发明第二套。 */
const ANCHOR = 'Affordance:'
const TRACE_LINES = ['Vocabulary scan:', 'Chosen:']
const NA_RE = /affordance:\s*n\/a\s*[—\-:]\s*\S+/i
const NA_BARE_RE = /affordance:\s*n\/a/i

export function checkMessage(msg) {
  const text = String(msg || '')
  const anchor = text.includes(ANCHOR)
  const na = NA_BARE_RE.test(text)
  const naRationale = NA_RE.test(text)
  if (na) return { anchor, na, naRationale, missing: naRationale ? [] : [`${ANCHOR} N/A 缺一句理由`] }
  return { anchor, na, naRationale, missing: anchor ? TRACE_LINES.filter((p) => !text.includes(p)) : [ANCHOR, ...TRACE_LINES] }
}

export function evaluateCommit({ diffText, message, iconIds }) {
  const hits = addedIconHits(diffText, iconIds)
  if (hits.length === 0) return { triggered: false, hits, ok: true, reasons: [] }
  const c = checkMessage(message)
  return {
    triggered: true,
    hits,
    ok: c.missing.length === 0,
    reasons: c.missing.map((p) => `commit message 缺「${p}」`),
  }
}
```

`main()` 的出口顺序（⚠️ meta-rules 触发器 R 案例 1/2 就是被提前 exit 0 架空的）：

1. 无参 / 参数形态不认 → `exit 1`（**不许 exit 0**）
2. 读两份活源、建闭集 → 任一 throw 直接冒泡 `exit 1`（fail closed 排在**所有** skip 之前）
3. merge 跳过（钩子档：`.git/MERGE_HEAD` 存在；区间档：`git rev-list --parents` 该 commit 父数 ≥ 2）
4. 取 diff：钩子档 `git diff --cached -M`；区间/单 commit 档 `git show -M --format= <sha>`
5. 逐 commit 判 `evaluateCommit`，任一不过 → 汇总点名后 `exit 1`
6. 全过 → 打印**会变的量**（受管闭集大小 / 扫了几个 commit / 触发了几个）后 `exit 0`

`--range` 档的区间为空时**不是**静默通过：打印「区间解析为 0 个 commit」并 `exit 1`（防 `push:master` 上区间恒空那类假绿，触发器 R 案例 #4）。

- [ ] **Step 4: 跑测试确认全绿 + 全量单测不回归**

Run: `pnpm vitest run tests/audit-commit-affordance-trace.test.ts` → PASS 19/19
Run: `pnpm test` → 不引入新红

- [ ] **Step 5: 无参调用亲验（防 F116 那类恒绿 step）**

Run: `bash -c 'node scripts/audit-commit-affordance-trace.mjs > /tmp/na.log 2>&1; echo "EXIT=$?"'`
Expected: `EXIT=1`

- [ ] **Step 6: Commit**（同上形态）

---

### Task 4: L4 挂载 —— `.husky/commit-msg`

**Files:**
- Create: `.husky/commit-msg`

- [ ] **Step 1: 写钩子**

```sh
#!/usr/bin/env sh
set -e

# R13 §Acceptance 的 commit-message 半边（INFRA-F123 ①）。
# 判据 / 触发面 / 排除项的真源 = scripts/audit-commit-affordance-trace.mjs 头注释。
# ⛔ 这里刻意不加条件触发：判据自己按 `git diff --cached` 决定要不要开火 ——
#    条件块写在这里就等于把触发面拆成两处会漂的副本。
echo "▶ commit-msg: audit:commit-affordance-trace (R13 3 行 trace)"
node scripts/audit-commit-affordance-trace.mjs --message-file "$1"
```

- [ ] **Step 2: 可执行位**

Run: `chmod +x .husky/commit-msg && ls -l .husky/commit-msg`
Expected: `-rwxr-xr-x`

- [ ] **Step 3: 直接调用亲验两个方向（⛔ 不靠 git，worktree 的 hooksPath 指主仓）**

阴性（未触发）:
```bash
printf 'docs: x\n' > /tmp/m1.txt
bash -c 'node scripts/audit-commit-affordance-trace.mjs --message-file /tmp/m1.txt > /tmp/h1.log 2>&1; echo "EXIT=$?"'
```
Expected: `EXIT=0`（当前 staged 区无 affordance 图标新增）

阳性（造真 staged 触发）:
```bash
mkdir -p src/__probe__ && printf 'import { IconClose } from "../icons"\n' > src/__probe__/Probe.vue
git add src/__probe__/Probe.vue
printf 'feat: probe\n' > /tmp/m2.txt
bash -c 'node scripts/audit-commit-affordance-trace.mjs --message-file /tmp/m2.txt > /tmp/h2.log 2>&1; echo "EXIT=$?"'
cat /tmp/h2.log
git reset -- src/__probe__/Probe.vue && rm -rf src/__probe__
```
Expected: `EXIT=1` + 日志点名 `src/__probe__/Probe.vue` 与 `IconClose`，并列出缺的 3 行

- [ ] **Step 4: git 驱动的那条路径（单次 config 覆盖，⛔ 不写共享 config）**

```bash
ln -sfn ../../../../.husky/_ .husky/_   # worktree 缺 shim；跑完保留（pre-commit 也需要）
# 用 -c 单次覆盖，让 shim 解析到 worktree 自己的 .husky/
git -c core.hooksPath=.husky/_ commit -F /tmp/m2.txt -- <path>   # 见 Task 6 真实提交
```
Expected: 提交输出里出现 `▶ commit-msg:` 一行。⚠️ 判据是**这行的出现**，不是 exit code（上一轮实证：钩子一个都没跑时 `git commit` 照样 exit 0）

- [ ] **Step 5: Commit**（连同 Task 5 一起提，见下）

---

### Task 5: L5 挂载 + 元闸扩面

**Files:**
- Modify: `package.json`（加 `audit:commit-affordance-trace`）
- Modify: `.gitea/workflows/pr-checks.yml`
- Modify: `scripts/audit-gate-ci-parity.mjs`
- Modify: `tests/audit-gate-ci-parity.test.ts`

- [ ] **Step 1: package.json 加 script**

```json
"audit:commit-affordance-trace": "node scripts/audit-commit-affordance-trace.mjs"
```

- [ ] **Step 2: pr-checks.yml 加 step（两条触发各自解区间）**

```yaml
      # R13 §Acceptance 的 commit-message 半边（INFRA-F123 ①）。判据真源 = 脚本头注释。
      # ⛔ 区间不许写 `origin/master...HEAD`：那形态在 push:master 上恒空（meta-rules 触发器 R
      #    案例 #4 实证）⇒ 闸恒 skip = master 面零覆盖。两条触发各取自己的事件载荷。
      # 前置 fetch-depth: 0 已在上方 Checkout 里（区间要真历史，depth:1 会让区间恒空）。
      - name: R13 commit-message affordance trace
        run: |
          set -e
          if [ "${{ github.event_name }}" = "pull_request" ]; then
            RANGE="${{ github.event.pull_request.base.sha }}..${{ github.event.pull_request.head.sha }}"
          else
            BEFORE="${{ github.event.before }}"
            case "$BEFORE" in
              ''|*[!0]*) : ;;
              *) echo "push 事件的 before 全零（新建分支 / force-push）—— 区间无法解析，fail closed"; exit 1 ;;
            esac
            RANGE="${BEFORE}..${{ github.event.after }}"
          fi
          echo "range=$RANGE"
          node scripts/audit-commit-affordance-trace.mjs --range "$RANGE"
```

- [ ] **Step 3: 元闸 A 侧扩到 hook 家族**

`scripts/audit-gate-ci-parity.mjs`：
- `const PRE_COMMIT = …` → `const L4_HOOKS = ['pre-commit', 'commit-msg']`
- `computeParity({ preCommitText, … })` → `computeParity({ hookTexts, … })`，A = 各 hook 文本的并集；`hookTexts` 为空或解析出 0 条闸 ⇒ 沿用现有 `throw`
- `readHooks()`：逐个 `existsSync`，缺一个就 `throw`（fail closed —— 钩子被摘掉正是该响的那一刻）
- 头注释同步改（⚠️ 它自己就是「挂载真源」；⛔ 注意别把「挂载层」段挤到 45 行之后）

- [ ] **Step 4: 元闸单测跟随 + 三条新断言**

```ts
it('commit-msg 上的闸也进 A', () => {
  const { a } = computeParity({
    hookTexts: ['node scripts/x.mjs', 'node scripts/audit-commit-affordance-trace.mjs'],
    pkgScripts: { prepublishOnly: 'node scripts/x.mjs' },
    workflowTexts: ['run: node scripts/x.mjs'],
  })
  expect(a).toContain('scripts/audit-commit-affordance-trace.mjs')
})

it('commit-msg 上的闸没进 CI ⇒ 进差集', () => {
  const { diff } = computeParity({
    hookTexts: ['node scripts/x.mjs', 'node scripts/only-hook.mjs'],
    pkgScripts: { prepublishOnly: 'node scripts/x.mjs' },
    workflowTexts: ['run: node scripts/x.mjs'],
  })
  expect(diff).toEqual(['scripts/only-hook.mjs'])
})

it('hookTexts 全空 ⇒ throw（判定面塌了，不是「没有 L4-only 闸」）', () => {
  expect(() => computeParity({ hookTexts: [], pkgScripts: { prepublishOnly: 'node scripts/x.mjs' }, workflowTexts: ['x'] })).toThrow()
})
```

- [ ] **Step 5: 亲跑元闸，确认差集仍为 0**

Run: `bash -c 'node scripts/audit-gate-ci-parity.mjs > /tmp/parity.log 2>&1; echo "EXIT=$?"'; cat /tmp/parity.log`
Expected: `EXIT=0`，自印行里 A 的条数比改动前 **+1**，差集 `0`

- [ ] **Step 6: 亲跑挂载声明元闸**

Run: `bash -c 'node scripts/audit-gate-mount-declaration.mjs > /tmp/mount.log 2>&1; echo "EXIT=$?"'`
Expected: `EXIT=0`（新脚本的「挂载层」段在头 45 行内）

- [ ] **Step 7: `--range` 档在真历史上跑一次（阳性对照，防「解析对了但区间取不到东西」）**

```bash
bash -c 'node scripts/audit-commit-affordance-trace.mjs --range HEAD~3..HEAD > /tmp/range.log 2>&1; echo "EXIT=$?"'; cat /tmp/range.log
```
Expected: `EXIT=0` + 自印「扫了 3 个 commit」；若打印 0 个 commit ⇒ 区间解析坏了，停下修

- [ ] **Step 8: Commit**

---

### Task 6: 故障注入清单（触发器 R L2 硬要求：commit 正文必附）

**Files:** 无（只跑，不改）

⚠️ meta-rules 触发器 R 问题 3 逐字：**逐个分母单侧打，只打「全部清零」不算**。

- [ ] **Step 1: 逐条注入并记录真实退出码**

| # | 注入 | 期望 |
|---|---|---|
| a | R13 表头 `必扫关键词` 改名（临时） | `EXIT=1` + 「关键词矩阵解析为 0 行」 |
| b | 关键词矩阵在场，但 `ICON_INDEX` 指向空文件 | `EXIT=1` + 「图标闭集为空」 |
| c | 触发 + message 无锚点 | `EXIT=1`，点名缺 3 行 |
| d | 触发 + 有锚点、**只缺** `Vocabulary scan:` | `EXIT=1`，**只**点名这一行 |
| e | 触发 + 有锚点、**只缺** `Chosen:` | `EXIT=1`，**只**点名这一行 |
| f | 触发 + 3 行齐全 | `EXIT=0` |
| g | 未触发 + 无 trace | `EXIT=0`（⛔ 不恒红） |
| h | `Affordance: N/A` 无理由 / 带理由 | `EXIT=1` / `EXIT=0` |
| i | 只改 `src/icons/generated/action.ts` | 不触发 `EXIT=0`（否则每次图标 sync 恒红） |
| j | 纯 rename（`-M`） | 不触发 `EXIT=0` |
| k | `--range` 区间解析出 0 个 commit | `EXIT=1`（不静默通过） |
| l | 无参 | `EXIT=1` |
| m | 元闸：把 `.husky/commit-msg` 里的闸调用注释掉 | `audit:gate-ci-parity` 仍 `EXIT=0`（A 少一条、B 少一条），**但** 把 pr-checks 那 step 删掉而保留钩子 ⇒ `EXIT=1` 点名新闸 |

- [ ] **Step 2: 把这张表（连真实退出码）写进 commit message 正文**

触发器 R 的 L2 那半是硬要求：⛔ 不接受「我读过代码，觉得会跑」。

- [ ] **Step 3: 真提一次含 affordance 图标的 commit，走 git 驱动的钩子**

用 Task 4 Step 4 的 `git -c core.hooksPath=.husky/_`，确认 `▶ commit-msg:` 出现在输出里（⛔ 判据是这行，不是 exit code）。

---

### Task 7: 文档收口

**Files:**
- Modify: `docs/internal/backlog.md`（F123 ①）
- Modify: `docs/internal/code-conventions.md`（§R13 §Acceptance 补一行 enforcement 层级）
- Modify: `docs/STATUS.md` · `docs/internal/STATUS-CHANGELOG.md` · tracker

- [ ] **Step 1: backlog F123 ① 收紧**

- 「剩 2.5 条」→ **剩 2 条**（R0 / M48），R13 那半改记为**已上闸**
- 现取判据里 `ls .husky/commit-msg` 那条**必须换掉**（它现在会 EXIT=0 = 已闭合），换成 R0/M48 那条仍为阴性的判据 + 新闸的正向判据
- ⛔ **entry 不删档**（① 仍开）
- ⛔ 判据里别放本轮新写进闸头注释的字面量（上一轮踩过两次）

- [ ] **Step 2: code-conventions §R13 §Acceptance 补 enforcement 行**

按 meta-rules 触发器 K：声明 `L4 (.husky/commit-msg) + L5 (pr-checks.yml 两条触发)`，并如实登记**边界**：
① `Affordance: N/A — <理由>` 档**无闸可核**（同 handoff 半边边界 ①）
② 触发面是**图标标识符闭集**，自画 SVG / Unicode 字形走后果面的闸（`audit:product-code` R1 / GLYPH 臂）
③ 触发面排除 `src/icons/**` 与 `tests/`

- [ ] **Step 3: 跑一次 doc-shape 取现值再写 STATUS**（⛔ 别抄交接里的数）

Run: `pnpm audit:doc-shape 2>&1 | grep -iE 'S1|STATUS'`

- [ ] **Step 4: STATUS 顶部摘要 + CHANGELOG prepend + tracker 批次 C 行同步**

⚠️ `audit:status-consistency` **C5** 要求顶部摘要里 `Active N → M` 的终值 == 活源且**恰好一处** —— 本轮无删档 ⇒ 终值仍 30，措辞要让 C5 过（先读 C5 判据再写）。

- [ ] **Step 5: 全链亲跑**

```bash
bash -c 'pnpm test > /tmp/t.log 2>&1; echo "EXIT=$?"'
bash -c 'node scripts/audit-status-consistency.mjs > /tmp/sc.log 2>&1; echo "EXIT=$?"'
bash -c 'node scripts/audit-gate-ci-parity.mjs > /tmp/p.log 2>&1; echo "EXIT=$?"'
```

- [ ] **Step 6: Commit + push**

`git commit -F <msgfile> -- <显式路径>` → `git reset -- <同路径>` → `git push origin worktree-round26-f123-r13-commitmsg:master`
落地后以 `git ls-remote` 核（本机 GitHub 偶发 remote-rejected 实为 ref 已更新）。

---

## Self-Review

**1. Spec coverage** —— R13 §Acceptance 三要素：3 行格式（Task 3 `checkMessage`）· commit message 落点（Task 4 L4）· probe `git log --grep`（Task 3 `--range` 档就是它的机械化）。backlog F123 ① 逐字要的两件：`.husky/commit-msg`（Task 4）+ 「它自己的 CI 可达面设计」（Task 5 Step 2，且逐字处理了 entry 点出的「commit message 在 CI 侧只能扫 push 区间 git log」）✓

**2. Placeholder scan** —— 无 TBD / 无「适当处理错误」；每个代码步都给了真实代码块；故障注入表给了逐条期望退出码 ✓

**3. Type consistency** —— `parseAffordanceKeywords → Map` 被 `collectAffordanceIconIds` 消费（Task 1↔2）· `addedIconHits` 被 `evaluateCommit` 消费（Task 2↔3）· `computeParity({hookTexts})` 在 Task 5 Step 3 改、Step 4 的测试用同一个名字 ✓

## 已知边界（如实登记，⛔ 不是待办）

1. **N/A 档无闸可核** —— 与已上闸的 handoff 半边同一条边界；要核就得回 Figma / diff 侧数新增 instance
2. **触发面是图标标识符闭集** ⇒ 只守「用了库图标但漏掉更一致的那个」这一类；自画 SVG / Unicode / emoji 由后果面的闸守
3. **worktree 里 git 驱动的钩子跑主仓那份**（`core.hooksPath` 是主仓绝对路径 + shim 用 `dirname(dirname($0))` 解析）⇒ 本计划的 L4 验证靠直接调用 + 单次 `-c` 覆盖；合并到 master 后才是常态路径
4. **consumer 侧不镜像**（handoff 半边有 `templates/.githooks/` 镜像，本轮刻意没做）—— `templates/` 在 `files[]` 内、镜像要过 changeset + 版本号，而 R13 的 consumer 对位物是已发布的 `audit:product-code` R1。⛔ 这是**范围裁定**不是遗漏，要不要镜像交 owner
