# INFRA-F133 `audit:claim-vs-livesource` Implementation Plan

> **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:** 给「指令型文档里的 prop / 导出名断言」上一道机械闸，让机器代替人做「对活源核」这个动作，把 AGENTS.md 硬规则 #10 的一个形态从 L1 抬到 L4+L5。

**Architecture:** 纯 node 静态分析。活源侧从 `src/canonical/*.vue` 的 `defineProps<{...}>` 与两个 barrel 提取真实 prop / 导出名；文档侧只扫**结构化区域**（markdown 表格同行 / 定义列表 / 固定字段块），两边对不上即红。判据分 S1–S5（S1–S4 阻塞、S5 report-only），只判 staged / PR-changed 文件。

**Tech Stack:** Node ESM (`.mjs`)、vitest、husky pre-commit、Gitea Actions

**Spec:** [`docs/superpowers/specs/2026-08-20-claim-vs-livesource-gate-design.md`](../specs/2026-08-20-claim-vs-livesource-gate-design.md)

## Global Constraints

- **基线 commit**：`85d0f93d`（spec 提交点）。起手第一件事是 `git log -1 AGENTS.md` 核 #10 正文是否又已变（spec §3.1 就是这么中招的）。
- **取号**：`INFRA-F133` 取自 **`origin/master` 三份 SoT 的 max+1**（2026-08-20 实测 max=132），**不是** `pnpm new-backlog` 的输出。
  ⚠️ 本计划初稿用的是 `INFRA-F132`，写完几十分钟后就被并行 session 的 `5cd26e41` 占用（该 commit message：「第十七轮收尾 …… 立成 F132」）。
  当时本地 `[behind 2]`，而 `pnpm new-backlog` **只扫本地 SoT** ⇒ 它会自信地给出一个远端已占用的号。
  ⇒ **落地时先 `git fetch`，再从 `origin/master` 核 max**，别只信 `new-backlog`（#10：全局命名空间的活源是 `origin/master`）。
  这个工具缺口本身登记为 Task 6 的第三条 entry。
- **可测范式**（照 `scripts/audit-layout-tokens.mjs`）：纯函数 `export` + 文件尾 `IS_MAIN` guard `if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) { main() }`。⛔ 没有 guard 就不可单测（`audit-hard-rule-compliance.mjs` 现在的病）。
- **CI 侧调用**：`run: node scripts/audit-claim-vs-livesource.mjs <args>`，**不用 `pnpm run`** —— 避开仓库注释点名的 `pnpm -- ` 参数透传坑。
- **双挂**：pre-commit 与 `pr-checks.yml` 必须同判据，否则 `audit:gate-ci-parity` 报红。
- **扫描面排除**：`.claude/worktrees/`（20+ worktree 各有计划文件副本，不排除同一 finding 重复 20 次）与 `docs/_archive/`。
- **判据正文留 `.md`**，脚本只放检测手段（meta-rules 反模式 #1）。
- **退档条件**：校准后假阳性率仍 ≥ 10% → S2 退 report-only，不上阻塞档。
- 行宽与既有脚本一致，注释用中文，与 `audit-layout-tokens.mjs` 风格对齐。

---

## File Structure

| 文件 | 职责 |
|---|---|
| `scripts/lib/canonical-props.mjs` | **新增**。canonical SFC 的 `defineProps` 解析器（括号配平）+ barrel 导出名解析。唯一职责：读代码活源，产出 prop / 导出名事实。 |
| `tests/lib-canonical-props.test.ts` | 上者的单测，含跨行嵌套的脆性防护用例。 |
| `scripts/audit-claim-vs-livesource.mjs` | **新增**。闸主体：S1–S5 判据 + `main()` 输出。不自己解析 SFC，调 lib。 |
| `tests/audit-claim-vs-livesource.test.ts` | 闸单测 + 07-30 历史 bug 回归 + CLI 故障注入。 |
| `figma-data/audit-allowlist/claim-exempt.json` | **新增**。shrink-only 豁免表，初始 `[]`。 |
| `package.json` | **改**。加 `audit:claim-vs-livesource` script。 |
| `.husky/pre-commit` | **改**。条件触发块。 |
| `.gitea/workflows/pr-checks.yml` | **改**。同判据 step。 |
| `AGENTS.md` | **改**。#10 的 Enforcement 列（按 spec §10 清单）。 |
| `docs/meta-rules.md` | **改**。触发器 K 登记。 |
| `docs/internal/backlog.md` | **改**。新增三条 entry：`INFRA-F133`（本闸留观察窗）、`F134`（三处 defineProps 解析待收敛）、`F135`（`new-backlog` 只扫本地 SoT）。号需按 Task 6 Step 1 重新核。 |

### 一条 spec 之外的发现（Task 1 的由来）

本仓**三个脚本各写了一套 `defineProps` 解析**：`audit-binding-config-parity.mjs`（第 71 行 `extractDefineProps`，括号配平，未 export）、`audit-component-affordances.mjs`、`audit-framework-api-floor.mjs`。这正是 `scripts/lib/rule-ids.mjs` 头注释记录的病（「分开维护两份正是那个 lib 当初被抽出来要治的病」）。

**本计划的处置**：抽出 lib 供**新闸**使用，**不改那三条活闸**（改它们要动三条 L4/L5 活路径，超出本 spec 范围，沿用 `audit-rule-inventory.mjs` 的先例裁定）。Task 6 会把「三处收敛」立成独立 backlog entry。

**已实测（2026-08-20，基线 `85d0f93d`）**：39 个 canonical 组件中 **0 个**有跨行嵌套对象 props，所以现有解析器当前是正确的；那 15 个含 `: {` / `Array<{` 的都是**单行内嵌套**，对括号配平与逐行正则均无害。lib 仍要加跨行防护 + 单测，因为那是将来才会踩的脆性。

---

## Task 1: `scripts/lib/canonical-props.mjs` — 活源解析器

**Files:**
- Create: `scripts/lib/canonical-props.mjs`
- Test: `tests/lib-canonical-props.test.ts`

**Interfaces:**
- Consumes: 无（本任务是最底层）
- Produces:
  - `parseDefinePropsBlock(sfcText: string) => string | null` — 取出 `defineProps<{ … }>` 内的原始块文本，找不到返回 `null`
  - `extractProps(sfcText: string) => Map<string, string> | null` — prop 名 → 类型表达式；无 `defineProps` 返回 `null`
  - `extractBarrelExports(tsText: string) => Set<string>` — 解析 `export { A, B as C }` 与 `export type { … }` 块里的对外名
  - `collectCanonicalProps(repoRoot: string) => Map<string, Map<string, string>>` — 组件名 → props（`Breadcrumb` 这类零 props 组件值为空 Map，**不是** null）

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

创建 `tests/lib-canonical-props.test.ts`：

```typescript
import { describe, it, expect } from 'vitest'
import { resolve } from 'node:path'
import {
  parseDefinePropsBlock,
  extractProps,
  extractBarrelExports,
  collectCanonicalProps,
} from '../scripts/lib/canonical-props.mjs'

const REPO_ROOT = resolve(__dirname, '..')

describe('parseDefinePropsBlock', () => {
  it('withDefaults 包裹也能取到块', () => {
    const sfc = `<script setup lang="ts">
const props = withDefaults(defineProps<{
  fill?: string
  size?: number
}>(), { fill: 'solid' })
</script>`
    expect(parseDefinePropsBlock(sfc)).toContain('fill?: string')
  })

  it('裸 defineProps 也能取到块', () => {
    const sfc = `defineProps<{ name: string }>()`
    expect(parseDefinePropsBlock(sfc)).toContain('name: string')
  })

  it('无 defineProps → null（零 props 组件的形态）', () => {
    expect(parseDefinePropsBlock('<script setup lang="ts">\nconst x = 1\n</script>')).toBeNull()
  })

  it('单行内嵌套对象不提前截断（当前 39 个组件的真实形态）', () => {
    const sfc = `defineProps<{
  items?: Array<{ id: string; label: string }>
  fill?: string
}>()`
    const block = parseDefinePropsBlock(sfc)
    expect(block).toContain('items?')
    expect(block).toContain('fill?')
  })

  it('跨行嵌套对象也不截断（将来才会踩的脆性）', () => {
    const sfc = `defineProps<{
  config?: {
    nested: string
  }
  fill?: string
}>()`
    const block = parseDefinePropsBlock(sfc)
    expect(block).toContain('fill?')
  })
})

describe('extractProps', () => {
  it('跨行嵌套的内层字段不得被当成顶层 prop', () => {
    const sfc = `defineProps<{
  config?: {
    nested: string
  }
  fill?: string
}>()`
    const props = extractProps(sfc)
    expect([...props!.keys()].sort()).toEqual(['config', 'fill'])
    expect(props!.has('nested')).toBe(false)
  })

  it('保留 inline union 的完整类型表达式', () => {
    const props = extractProps(`defineProps<{ size?: number | string }>()`)
    expect(props!.get('size')).toBe('number | string')
  })
})

describe('extractBarrelExports', () => {
  it('解析多行 export 块与 as 别名', () => {
    const ts = `export {
  Button,
  Badge as TvuBadge,
} from './canonical'
export type { TableColumn } from './components/Table/Table.vue'`
    const names = extractBarrelExports(ts)
    expect(names.has('Button')).toBe(true)
    expect(names.has('TvuBadge')).toBe(true)
    expect(names.has('Badge')).toBe(false) // 别名后的对外名才算
    expect(names.has('TableColumn')).toBe(true)
  })
})

describe('collectCanonicalProps（活源实测锚点）', () => {
  const live = collectCanonicalProps(REPO_ROOT)

  it('Breadcrumb 是零 props（空 Map，不是 null）', () => {
    expect(live.get('Breadcrumb')).toBeInstanceOf(Map)
    expect(live.get('Breadcrumb')!.size).toBe(0)
  })

  it('Table 含 rowKey / selectedKeys / loading', () => {
    const t = live.get('Table')!
    expect(t.has('rowKey')).toBe(true)
    expect(t.has('selectedKeys')).toBe(true)
    expect(t.has('loading')).toBe(true)
  })

  it('Tab 与 TabList 的 props 不相交于 items（二选一的机械证据）', () => {
    expect(live.get('Tab')!.has('items')).toBe(false)
    expect(live.get('TabList')!.has('items')).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/lib-canonical-props.test.ts`
Expected: FAIL — `Failed to resolve import "../scripts/lib/canonical-props.mjs"`

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

创建 `scripts/lib/canonical-props.mjs`：

```javascript
// scripts/lib/canonical-props.mjs — canonical 活源解析（INFRA-F133）
//
// 为什么是 lib：本仓已有三处各写一套 defineProps 解析
// （audit-binding-config-parity / audit-component-affordances / audit-framework-api-floor），
// 正是 scripts/lib/rule-ids.mjs 头注释记录的那个病。本 lib 是**第四份的正确落点**：
// 新闸只用它；那三处的收敛另立 backlog（改活闸超出 INFRA-F133 范围）。
//
// 括号配平取块的逻辑照 audit-binding-config-parity.mjs:71 的 extractDefineProps，
// 但补两处它没有的：① 跨行嵌套时内层字段不得混入顶层 prop（当前 0/39 命中，属将来脆性）
// ② 零 props 组件返回空 Map 而非 null，让调用方能区分「没有 props」与「解析失败」。
import { readFileSync, readdirSync, existsSync } from 'node:fs'
import { resolve } from 'node:path'

export const CANONICAL_DIR = 'src/canonical'
export const BARREL_FILES = ['src/index.ts', 'src/canonical/index.ts']

/**
 * 取 `defineProps<{ … }>` 内的原始块文本。withDefaults 包裹与裸调用都吃。
 * @returns {string|null} 找不到 defineProps 时 null
 */
export function parseDefinePropsBlock(sfcText) {
  const marker = 'defineProps<{'
  const startIdx = sfcText.indexOf(marker)
  if (startIdx === -1) return null

  const blockStart = startIdx + marker.length
  let depth = 0
  let blockEnd = -1
  for (let i = blockStart; i < sfcText.length; i++) {
    const ch = sfcText[i]
    if (ch === '{') depth++
    else if (ch === '}') {
      if (depth === 0) { blockEnd = i; break }
      depth--
    }
  }
  if (blockEnd === -1) return null
  return sfcText.slice(blockStart, blockEnd)
}

/**
 * prop 名 → 类型表达式。只取**顶层**键：按大括号深度过滤，深度 > 0 的行是嵌套内层，跳过。
 * @returns {Map<string,string>|null} 无 defineProps 时 null
 */
export function extractProps(sfcText) {
  const block = parseDefinePropsBlock(sfcText)
  if (block === null) return null

  const props = new Map()
  let depth = 0
  for (const rawLine of block.split('\n')) {
    const line = rawLine.trim()
    // 先按本行**起始**深度判定归属，再累计本行的括号增减
    if (depth === 0) {
      const m = /^(\w+)\??\s*:\s*(.+?)\s*$/.exec(line)
      if (m) props.set(m[1], m[2].replace(/[;,]$/, '').trim())
    }
    for (const ch of line) {
      if (ch === '{') depth++
      else if (ch === '}') depth = Math.max(0, depth - 1)
    }
  }
  return props
}

/** 解析 barrel 的对外名（`export { A, B as C }` / `export type { … }`）。 */
export function extractBarrelExports(tsText) {
  const names = new Set()
  const blockRe = /export\s+(?:type\s+)?\{([\s\S]*?)\}/g
  let m
  while ((m = blockRe.exec(tsText)) !== null) {
    for (const raw of m[1].split(',')) {
      const item = raw.trim()
      if (!item) continue
      // `B as C` 的对外名是 C；`default as D` 同理
      const asMatch = /^\S+\s+as\s+(\w+)$/.exec(item)
      names.add(asMatch ? asMatch[1] : item.replace(/^type\s+/, ''))
    }
  }
  return names
}

/**
 * 组件名 → props。零 props 组件值为**空 Map**（不是 null），便于闸区分
 * 「这个组件确实没有 props」与「解析失败」。
 */
export function collectCanonicalProps(repoRoot) {
  const dir = resolve(repoRoot, CANONICAL_DIR)
  const out = new Map()
  if (!existsSync(dir)) return out
  for (const name of readdirSync(dir)) {
    if (!name.endsWith('.vue')) continue
    const comp = name.replace(/\.vue$/, '')
    const txt = readFileSync(resolve(dir, name), 'utf8')
    const props = extractProps(txt)
    out.set(comp, props ?? new Map())
  }
  return out
}

/** 两个 barrel 的对外名并集。 */
export function collectBarrelExports(repoRoot) {
  const names = new Set()
  for (const rel of BARREL_FILES) {
    const p = resolve(repoRoot, rel)
    if (!existsSync(p)) continue
    for (const n of extractBarrelExports(readFileSync(p, 'utf8'))) names.add(n)
  }
  return names
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm vitest run tests/lib-canonical-props.test.ts`
Expected: PASS（全部用例）

⚠️ 若 `collectCanonicalProps` 那三条活源锚点用例失败，**不要改测试去迁就实现** —— 先手工核：

```bash
sed -n '/defineProps<{/,/}>()/p' src/canonical/Table.vue
```

活源真的变了才改用例，并在 commit message 里写明变更。

- [ ] **Step 5: Commit**

```bash
git add scripts/lib/canonical-props.mjs tests/lib-canonical-props.test.ts
git commit -m "feat(lib): canonical 活源解析器 —— INFRA-F133 Task 1

抽出 defineProps / barrel 导出名解析为 lib。括号配平取块照
audit-binding-config-parity.mjs:71，补两处它没有的：跨行嵌套内层字段不混入顶层
prop（当前 0/39 命中，属将来脆性）+ 零 props 组件返回空 Map 而非 null。

⛔ 不改那三条已有各自实现的活闸（超出 F133 范围，收敛另立 entry）。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Task 2: 闸骨架 + S1 / S3 fail-closed

**Files:**
- Create: `scripts/audit-claim-vs-livesource.mjs`
- Create: `figma-data/audit-allowlist/claim-exempt.json`
- Test: `tests/audit-claim-vs-livesource.test.ts`

**Interfaces:**
- Consumes: Task 1 的 `collectCanonicalProps(repoRoot)`、`collectBarrelExports(repoRoot)`；`audit-plan-lifecycle.mjs` 的 `MANAGED_DIRS`（形状 `{ dir, archiveDir, label }[]`）
- Produces:
  - `SCAN_EXCLUDES: string[]` — 路径片段黑名单
  - `buildLiveSource(repoRoot) => { props: Map<string, Map<string,string>>, exports: Set<string> }`
  - `evaluate({ live, claims, exemptions }) => { failures: {code, message, file?, line?}[], usedExemptions: Set<string> }`
  - failure code 命名：`S1-*` / `S2-*` / `S3-*` / `S4-*`

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

追加 `tests/audit-claim-vs-livesource.test.ts`：

```typescript
import { describe, it, expect } from 'vitest'
import { evaluate, SCAN_EXCLUDES } from '../scripts/audit-claim-vs-livesource.mjs'

const emptyLive = { props: new Map(), exports: new Set<string>() }
const liveWith = (comp: string, props: string[]) => ({
  props: new Map([[comp, new Map(props.map((p) => [p, 'string']))]]),
  exports: new Set([comp]),
})

describe('S1 活源分母 fail-closed', () => {
  it('props 与 exports 都空 → 红', () => {
    const { failures } = evaluate({ live: emptyLive, claims: [], exemptions: [] })
    expect(failures.some((f) => f.code === 'S1-no-live-source')).toBe(true)
  })

  it('活源非空且无断言 → 绿', () => {
    const { failures } = evaluate({ live: liveWith('Table', ['rowKey']), claims: [], exemptions: [] })
    expect(failures).toHaveLength(0)
  })
})

describe('S3 扫描面 fail-closed', () => {
  it('worktrees 与 _archive 在排除名单里', () => {
    expect(SCAN_EXCLUDES).toContain('.claude/worktrees')
    expect(SCAN_EXCLUDES).toContain('docs/_archive')
  })

  it('scanned=0 且有受管目录 → 红', () => {
    const { failures } = evaluate({
      live: liveWith('Table', ['rowKey']),
      claims: [],
      exemptions: [],
      scan: { managedDirCount: 4, fileCount: 0 },
    })
    expect(failures.some((f) => f.code === 'S3-empty-scan')).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts`
Expected: FAIL — 模块不存在

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

创建 `figma-data/audit-allowlist/claim-exempt.json`：

```json
[]
```

创建 `scripts/audit-claim-vs-livesource.mjs`（本 Task 只到 S1/S3，S2 在 Task 3 补）：

```javascript
// audit-claim-vs-livesource.mjs — INFRA-F133（文档断言 × 代码活源）
//
// 治的病：计划文件里写的具体值（prop 名 / 导出名）没有任何机制核对活源。
//   实证 2026-07-30 `docs/superpowers/plans/2026-07-30-v1x-next-batch-ai-consumable-page-layer.md`
//   Task 4 自记「草稿 4 条具体断言里 3 条要改」：Breadcrumb 其实零 props、
//   Tab 与 TabList 是二选一而非三件套、状态集漏 master-empty。
//
// 与既有闸的分工：**不要求作者提供证据，闸自己去核活源**。
//   （「要求作者挂 evidence」的形态实测不可用：18 份计划里 14 份 checkbox 是 0/N，
//     不存在可靠的"已执行"信号。详见 spec §4。）
//
// 判据（S1-S4 阻塞，S5 report-only）：
//   S1  活源分母 fail closed —— canonical props 与 barrel 导出都解析不出 → 红。
//   S2  结构化区域内的 prop / 导出名断言 × 活源不一致 → 红（见 Task 3）。
//   S3  扫描面 fail closed —— 有受管目录却一个文件都没扫到 → 红。
//   S4  豁免表 shrink-only —— 表里某条已不再命中 → 红，要求删行。
//   S5  report-only —— 散文区域的疑似断言只印不红（语义判定不可靠，见 spec §4）。
//
// ⛔ 只判 staged / PR-changed 文件，不全量扫 live tree。历史断言是历史记录，
//    组件演进后本不该与当前活源一致；全量扫会把 279 处历史 ghost 全报出来。
//
// Enforcement（meta-rules 触发器 K）：L4 = .husky/pre-commit 条件 gate；
//   L5 = .gitea/workflows/pr-checks.yml。两处必须同判据（audit:gate-ci-parity 硬要求）。
import { readFileSync, existsSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { collectCanonicalProps, collectBarrelExports } from './lib/canonical-props.mjs'
import { MANAGED_DIRS } from './audit-plan-lifecycle.mjs'

const __dirname = dirname(fileURLToPath(import.meta.url))
const REPO_ROOT = resolve(__dirname, '..')

export const EXEMPT_FILE = 'figma-data/audit-allowlist/claim-exempt.json'

/** 扫描面排除：worktree 副本会让同一 finding 重复 20+ 次；归档是历史记录。 */
export const SCAN_EXCLUDES = ['.claude/worktrees', 'docs/_archive', 'node_modules']

/** 受管目录（复用 plan-lifecycle 的同一份，⛔ 别在这里再列一遍）。 */
export const MANAGED_DIR_PATHS = MANAGED_DIRS.map((d) => d.dir)

export function buildLiveSource(repoRoot) {
  return {
    props: collectCanonicalProps(repoRoot),
    exports: collectBarrelExports(repoRoot),
  }
}

export function loadExemptions(repoRoot) {
  const p = resolve(repoRoot, EXEMPT_FILE)
  if (!existsSync(p)) return []
  try {
    const parsed = JSON.parse(readFileSync(p, 'utf8'))
    return Array.isArray(parsed) ? parsed : []
  } catch {
    // 豁免表坏了要红，不能静默当成空表放行
    return null
  }
}

export function evaluate({ live, claims = [], exemptions = [], scan = null }) {
  const failures = []
  const usedExemptions = new Set()

  // S1 活源分母 fail closed
  const propComponentCount = live.props ? live.props.size : 0
  const exportCount = live.exports ? live.exports.size : 0
  if (propComponentCount === 0 && exportCount === 0) {
    failures.push({
      code: 'S1-no-live-source',
      message:
        'canonical props 与 barrel 导出都解析不出 —— 分母为 0，闸会空转成假绿。' +
        '若真要退役 src/canonical 或两个 barrel，请连这条闸一起删。',
    })
  }

  // S3 扫描面 fail closed
  if (scan && scan.managedDirCount > 0 && scan.fileCount === 0) {
    failures.push({
      code: 'S3-empty-scan',
      message:
        `有 ${scan.managedDirCount} 个受管目录却一个 .md 都没扫到 —— 输入形态错，判红而不是通过。`,
    })
  }

  if (exemptions === null) {
    failures.push({
      code: 'S4-exempt-file-unreadable',
      message: `${EXEMPT_FILE} 解析失败 —— 豁免表坏了要红，不静默当空表放行。`,
    })
  }

  return { failures, usedExemptions }
}
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts`
Expected: PASS

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-claim-vs-livesource.mjs tests/audit-claim-vs-livesource.test.ts figma-data/audit-allowlist/claim-exempt.json
git commit -m "feat(gate): INFRA-F133 闸骨架 + S1/S3 fail-closed —— Task 2

活源分母为 0 判红（防把 canonical 删空让闸空转成假绿）；有受管目录却零文件判红。
受管目录 import 自 audit-plan-lifecycle.mjs，⛔ 不在本闸再列第二份。
扫描面排除 .claude/worktrees（20+ 副本会让同一 finding 重复 20 次）与 docs/_archive。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Task 3: S2 判据 + 07-30 历史 bug 回归

**Files:**
- Modify: `scripts/audit-claim-vs-livesource.mjs`
- Modify: `tests/audit-claim-vs-livesource.test.ts`

**Interfaces:**
- Consumes: Task 2 的 `evaluate`
- Produces:
  - `extractClaims(text: string) => { comp: string, ident: string, line: number, region: 'table'|'deflist'|'field' }[]`
  - `STRUCTURED_REGIONS: readonly ['table', 'deflist', 'field']`
  - `evaluate` 新增 failure code `S2-prop-not-in-live` / `S2-export-not-in-live`

**结构化区域定义（spec §6.1 初始档，逐字对齐）：**

| 区域 | 形态 | 归属如何确定 |
|---|---|---|
| `table` | `\| Component \| prop \| … \|` | 同行即同一断言 |
| `deflist` | `- \`Component\`：… \`prop\` …` | 行首标识符为宿主 |
| `field` | `Files:` / `Interfaces:` / `Produces:` 下的列表项 | 字段块的宿主声明 |

⛔ 三档都**不含散文行**。散文归 S5，不因"看起来像断言"进 S2。

- [ ] **Step 1: 写失败测试（含三条历史 bug 回归）**

追加到 `tests/audit-claim-vs-livesource.test.ts`：

```typescript
import { extractClaims } from '../scripts/audit-claim-vs-livesource.mjs'

describe('extractClaims 只收结构化区域', () => {
  it('表格同行的 组件+prop 被收', () => {
    const md = ['| 组件 | prop |', '|---|---|', '| `Table` | `rowKey` |'].join('\n')
    const claims = extractClaims(md)
    expect(claims).toEqual([
      expect.objectContaining({ comp: 'Table', ident: 'rowKey', region: 'table', line: 3 }),
    ])
  })

  it('散文行不被收（S2 不判散文）', () => {
    const md = '我们打算给 `Table` 增加一个 `magicFlag` 开关。'
    expect(extractClaims(md)).toHaveLength(0)
  })

  it('fenced code block 内不被收', () => {
    const md = ['```json', '{ "Table": { "ghostProp": 1 } }', '```'].join('\n')
    expect(extractClaims(md)).toHaveLength(0)
  })
})

describe('S2 —— 2026-07-30 三条历史真实错断言必须逐条抓到', () => {
  const live = {
    props: new Map([
      ['Breadcrumb', new Map()],
      ['Table', new Map([['rowKey', 'string'], ['selectedKeys', 'string[]'], ['loading', 'boolean']])],
      ['Tab', new Map([['modelValue', 'string'], ['fill', 'string'], ['color', 'string']])],
      ['TabList', new Map([['items', 'unknown[]'], ['modelValue', 'string'], ['fill', 'string']])],
    ]),
    exports: new Set(['Breadcrumb', 'Table', 'Tab', 'TabList', 'BreadcrumbItem', 'TabItem']),
  }

  it('① Breadcrumb 被说成有层级 props → 红', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Breadcrumb` | `level` |')
    const { failures } = evaluate({ live, claims, exemptions: [] })
    const hit = failures.find((f) => f.code === 'S2-prop-not-in-live')
    expect(hit).toBeDefined()
    expect(hit!.message).toContain('Breadcrumb')
    expect(hit!.message).toContain('零 props')
  })

  it('② Tab 被说成有 items（Tab/TabList 二选一）→ 红', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Tab` | `items` |')
    const { failures } = evaluate({ live, claims, exemptions: [] })
    expect(failures.some((f) => f.code === 'S2-prop-not-in-live' && f.message.includes('Tab'))).toBe(true)
  })

  it('③ Table 的真 props 不得误报（防假阳性）', () => {
    const claims = extractClaims(
      '| 组件 | prop |\n|---|---|\n| `Table` | `rowKey` |\n| `Table` | `selectedKeys` |'
    )
    const { failures } = evaluate({ live, claims, exemptions: [] })
    expect(failures).toHaveLength(0)
  })

  it('豁免表命中则放行，且该条记入 usedExemptions（S4 靠它判 shrink-only）', () => {
    const claims = extractClaims('| 组件 | prop |\n|---|---|\n| `Breadcrumb` | `level` |')
    const exemptions = [
      { comp: 'Breadcrumb', ident: 'level', reason: '测试用', added: '2026-08-20' },
    ]
    const { failures, usedExemptions } = evaluate({ live, claims, exemptions })
    expect(failures).toHaveLength(0)
    expect(usedExemptions.has('Breadcrumb::level')).toBe(true)
  })
})

describe('S4 豁免表 shrink-only', () => {
  it('豁免条目已不再命中 → 红，要求删行', () => {
    const live = { props: new Map([['Table', new Map([['rowKey', 'string']])]]), exports: new Set(['Table']) }
    const exemptions = [{ comp: 'Table', ident: 'rowKey', reason: '早已修好', added: '2026-08-01' }]
    const { failures } = evaluate({ live, claims: [], exemptions })
    expect(failures.some((f) => f.code === 'S4-stale-exemption')).toBe(true)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts`
Expected: FAIL — `extractClaims is not a function`

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

在 `scripts/audit-claim-vs-livesource.mjs` 中，`evaluate` 之前插入：

```javascript
export const STRUCTURED_REGIONS = ['table', 'deflist', 'field']

const FIELD_BLOCK_RE = /^\s*(?:\*\*)?(Files|Interfaces|Produces|Consumes)(?:\*\*)?\s*[:：]/
const BACKTICK_RE = /`([A-Za-z][\w.-]*)`/g

/**
 * 只从结构化区域抽断言。归属由结构本身表达：
 *   table   —— 同一行内的 `Comp` 与 `ident`
 *   deflist —— 行首 `- \`Comp\`` 之后同行的其余标识符
 *   field   —— Files:/Interfaces:/Produces:/Consumes: 块内的列表项
 * ⛔ 散文行一律不收（spec §4：语义判定不可靠）。
 */
export function extractClaims(text) {
  const claims = []
  const lines = text.split('\n')
  let inFence = false
  let inFieldBlock = false

  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]

    if (/^\s*```/.test(line)) { inFence = !inFence; continue }
    if (inFence) continue

    if (FIELD_BLOCK_RE.test(line)) { inFieldBlock = true; continue }
    if (inFieldBlock && line.trim() === '') { inFieldBlock = false }

    const isTableRow = /^\s*\|/.test(line) && line.includes('|', 1)
    const isTableSep = /^\s*\|[\s:|-]+\|?\s*$/.test(line)
    const isDefList = /^\s*[-*]\s+`[A-Za-z]/.test(line)

    let region = null
    if (isTableRow && !isTableSep) region = 'table'
    else if (isDefList) region = 'deflist'
    else if (inFieldBlock && /^\s*[-*]\s/.test(line)) region = 'field'
    if (!region) continue

    // 本行所有反引号标识符：首个大写开头的作宿主，其余小写开头的作 prop 断言
    const idents = [...line.matchAll(BACKTICK_RE)].map((m) => m[1])
    const comp = idents.find((s) => /^[A-Z]/.test(s))
    if (!comp) continue
    for (const ident of idents) {
      if (ident === comp) continue
      if (!/^[a-z]/.test(ident)) continue // 只判小写开头 = prop 形态
      claims.push({ comp, ident, line: i + 1, region })
    }
  }
  return claims
}
```

在 `evaluate` 的 S1 之后、S3 之前插入 S2 与 S4：

```javascript
  // S2 结构化区域断言 × 活源
  const exemptKey = (c, i) => `${c}::${i}`
  const exemptSet = new Set(
    (exemptions ?? []).map((e) => exemptKey(e.comp, e.ident))
  )

  for (const claim of claims) {
    const compProps = live.props ? live.props.get(claim.comp) : undefined
    if (compProps === undefined) continue // 不是 canonical 组件，本闸不管
    if (compProps.has(claim.ident)) continue // 断言正确

    const key = exemptKey(claim.comp, claim.ident)
    if (exemptSet.has(key)) { usedExemptions.add(key); continue }

    const liveList = compProps.size === 0
      ? '零 props（该组件只转发 attrs / 槽）'
      : [...compProps.keys()].join(', ')
    failures.push({
      code: 'S2-prop-not-in-live',
      line: claim.line,
      message:
        `第 ${claim.line} 行（${claim.region}）断言 \`${claim.comp}\` 有 prop ` +
        `\`${claim.ident}\`，但活源里没有。活源实况：${liveList}。` +
        `请改断言，或在 ${EXEMPT_FILE} 具名豁免（带 reason + added 日期）。`,
    })
  }

  // S4 豁免表 shrink-only：表里某条已不再命中 → 要求删行
  for (const e of exemptions ?? []) {
    const key = exemptKey(e.comp, e.ident)
    if (usedExemptions.has(key)) continue
    const compProps = live.props ? live.props.get(e.comp) : undefined
    const nowValid = compProps !== undefined && compProps.has(e.ident)
    if (nowValid || compProps === undefined) {
      failures.push({
        code: 'S4-stale-exemption',
        message:
          `${EXEMPT_FILE} 里的 \`${e.comp}.${e.ident}\` 已不再命中` +
          `（活源${nowValid ? '现在有这个 prop' : '里已无此组件'}）—— 删掉该行。` +
          `表空着是终态，不是待办。`,
      })
    }
  }
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts`
Expected: PASS，且「2026-07-30 三条历史真实错断言」那组全绿。

⛔ **验收硬线**：那三条抓不到就是没做成，不接受"判据合理但抓不到历史 bug"。

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-claim-vs-livesource.mjs tests/audit-claim-vs-livesource.test.ts
git commit -m "feat(gate): INFRA-F133 S2 判据 + 07-30 历史 bug 回归 —— Task 3

只从结构化区域（表格同行 / 定义列表 / 字段块）抽断言，散文一律不收 ——
'是不是断言'与'断言归属谁'都不是词法特征，扫散文实测 67% 假阳性（spec §4）。

回归用例锁死 2026-07-30 那三条真实错断言：Breadcrumb 零 props /
Tab 无 items（与 TabList 二选一）/ Table 真 props 不得误报。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Task 4: `main()` + S5 report-only + CLI 故障注入

**Files:**
- Modify: `scripts/audit-claim-vs-livesource.mjs`
- Modify: `tests/audit-claim-vs-livesource.test.ts`
- Modify: `package.json`

**Interfaces:**
- Consumes: Task 2/3 的 `evaluate`、`extractClaims`、`buildLiveSource`、`loadExemptions`
- Produces: CLI `node scripts/audit-claim-vs-livesource.mjs [--files <a.md,b.md>] [--all]`；退出码 0 = 通过、1 = 有违例、2 = 参数错

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

追加：

```typescript
import { execFileSync } from 'node:child_process'
import { resolve } from 'node:path'

describe('CLI 故障注入', () => {
  const script = resolve(__dirname, '../scripts/audit-claim-vs-livesource.mjs')
  const exitCodeOf = (args: string[]) => {
    try {
      execFileSync('node', [script, ...args], { stdio: 'pipe' })
      return 0
    } catch (e) {
      return (e as { status: number }).status
    }
  }

  it('未知参数 → exit 2（不静默放行）', () => {
    expect(exitCodeOf(['--bogus-flag'])).toBe(2)
  })

  it('无 staged 文件 → exit 0 且印跳过原因', () => {
    const out = execFileSync('node', [script, '--files', ''], { encoding: 'utf8' })
    expect(out).toMatch(/没有需要判定的文件|skipped/)
  })
})
```

- [ ] **Step 2: 跑测试确认失败**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts -t 'CLI 故障注入'`
Expected: FAIL — 未知参数当前 exit 0

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

在文件尾部追加：

```javascript
/** S5：散文行里的疑似断言，只印不红（语义不可靠，见 spec §4）。 */
export function collectProseSuspects(text, live) {
  const out = []
  const lines = text.split('\n')
  let inFence = false
  for (let i = 0; i < lines.length; i++) {
    const line = lines[i]
    if (/^\s*```/.test(line)) { inFence = !inFence; continue }
    if (inFence) continue
    if (/^\s*\|/.test(line) || /^\s*[-*]\s+`[A-Za-z]/.test(line)) continue // 结构化区域归 S2
    const idents = [...line.matchAll(BACKTICK_RE)].map((m) => m[1])
    const comp = idents.find((s) => /^[A-Z]/.test(s) && live.props.has(s))
    if (!comp) continue
    for (const ident of idents) {
      if (ident === comp || !/^[a-z]/.test(ident)) continue
      if (live.props.get(comp).has(ident)) continue
      out.push({ comp, ident, line: i + 1 })
    }
  }
  return out
}

function parseArgs(argv) {
  const opts = { files: null, all: false }
  for (let i = 0; i < argv.length; i++) {
    const a = argv[i]
    if (a === '--all') opts.all = true
    else if (a === '--files') { opts.files = (argv[++i] ?? '').split(',').filter(Boolean) }
    else return { error: `未知参数：${a}` }
  }
  return opts
}

function main() {
  const opts = parseArgs(process.argv.slice(2))
  if (opts.error) {
    console.error(`audit:claim-vs-livesource ✗ ${opts.error}`)
    console.error('用法：node scripts/audit-claim-vs-livesource.mjs [--files a.md,b.md] [--all]')
    process.exit(2)
  }

  const live = buildLiveSource(REPO_ROOT)
  const exemptions = loadExemptions(REPO_ROOT)

  // 判定面：默认只判传入的文件（staged / PR-changed）
  const targets = (opts.files ?? [])
    .filter((f) => f.endsWith('.md'))
    .filter((f) => MANAGED_DIR_PATHS.some((d) => f.startsWith(d)))
    .filter((f) => !SCAN_EXCLUDES.some((x) => f.includes(x)))

  console.log('audit:claim-vs-livesource — 覆盖面')
  console.log(`  canonical 组件 : ${live.props.size} 个`)
  console.log(`  barrel 导出名  : ${live.exports.size} 个`)
  console.log(`  受管目录       : ${MANAGED_DIR_PATHS.join(', ')}`)
  console.log(`  本次判定文件   : ${targets.length} 份`)
  console.log(`  豁免           : ${exemptions === null ? '<解析失败>' : exemptions.length} 条`)

  const claims = []
  const suspects = []
  for (const rel of targets) {
    const p = resolve(REPO_ROOT, rel)
    if (!existsSync(p)) continue
    const text = readFileSync(p, 'utf8')
    for (const c of extractClaims(text)) claims.push({ ...c, file: rel })
    for (const s of collectProseSuspects(text, live)) suspects.push({ ...s, file: rel })
  }

  const { failures } = evaluate({
    live,
    claims,
    exemptions,
    scan: { managedDirCount: MANAGED_DIR_PATHS.length, fileCount: targets.length },
  })

  console.log(
    `\naudit:claim-vs-livesource — S5 report-only：${suspects.length} 处散文疑似断言（不阻塞）`
  )
  for (const s of suspects) console.log(`  · ${s.file}:${s.line}  ${s.comp}.${s.ident}`)

  if (targets.length === 0) {
    console.log('\naudit:claim-vs-livesource ✓ 没有需要判定的文件（skipped）')
    process.exit(0)
  }

  if (failures.length) {
    console.error(`\naudit:claim-vs-livesource ✗ ${failures.length} 条违例：`)
    for (const f of failures) {
      const loc = f.file ? `${f.file}${f.line ? ':' + f.line : ''} ` : ''
      console.error(`  [${f.code}] ${loc}${f.message}`)
    }
    process.exit(1)
  }
  console.log(`\naudit:claim-vs-livesource ✓ ${claims.length} 条结构化断言全部对得上活源`)
  process.exit(0)
}

if (process.argv[1] && resolve(process.argv[1]) === resolve(fileURLToPath(import.meta.url))) {
  main()
}
```

⚠️ `failures` 里 S2 的条目需要带 `file`。在 Task 3 的 S2 循环里把 `file: claim.file` 一并写入 failure 对象（`claim` 已在 `main()` 里被 spread 加上 `file`）。

`package.json` 的 `scripts` 加一行（放在 `audit:canonical-slot-guard` 附近，保持字母序）：

```json
"audit:claim-vs-livesource": "node scripts/audit-claim-vs-livesource.mjs",
```

- [ ] **Step 4: 跑测试确认通过**

Run: `pnpm vitest run tests/audit-claim-vs-livesource.test.ts`
Expected: PASS（全部，含 CLI 那两条）

再手工跑一次真文件，确认输出形态：

```bash
node scripts/audit-claim-vs-livesource.mjs --files docs/superpowers/plans/2026-07-30-v1x-next-batch-ai-consumable-page-layer.md
```

- [ ] **Step 5: Commit**

```bash
git add scripts/audit-claim-vs-livesource.mjs tests/audit-claim-vs-livesource.test.ts package.json
git commit -m "feat(gate): INFRA-F133 main() + S5 report-only + CLI 故障注入 —— Task 4

未知参数 exit 2（不静默放行）；无判定面 exit 0 且印跳过原因。
覆盖面每次运行自印（别据此外推'文档断言已收口'）。
S5 把散文那一半做成只印不红，积累数据后再决定升不升格。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Task 5: 挂 pre-commit + CI，并跑校准

**Files:**
- Modify: `.husky/pre-commit`
- Modify: `.gitea/workflows/pr-checks.yml`

**Interfaces:**
- Consumes: Task 4 的 CLI
- Produces: L4 + L5 双挂

- [ ] **Step 1: 挂 pre-commit**

在 `.husky/pre-commit` 里 `typecheck 作用面 gate (INFRA-F130)` 那块之后追加：

```sh
echo "▶ pre-commit: 文档断言 × 代码活源 gate (INFRA-F133)"
# 计划文件里的具体值（prop / 导出名）没有机制核活源 —— 2026-07-30 实证草稿 4 条断言 3 条错。
# 只判 staged 的指令型 .md；活源侧变了也要复跑（canonical / barrel / 闸自身 staged 时）。
CLAIM_FILES=$(git diff --cached --name-only --diff-filter=AM \
  | grep -E '^(docs/superpowers/plans|docs/internal/_prompts|docs/internal/_plans|docs/internal/_handoffs)/.*\.md$' \
  | paste -sd, -)
if [ -n "$CLAIM_FILES" ] || git diff --cached --name-only --diff-filter=AM | grep -qE '(src/canonical/.*\.vue|src/index\.ts|src/canonical/index\.ts|scripts/audit-claim-vs-livesource\.mjs|scripts/lib/canonical-props\.mjs)'; then
  node scripts/audit-claim-vs-livesource.mjs --files "$CLAIM_FILES"
else
  echo "   ✓ no claim-vs-livesource-relevant files staged, skipped"
fi
```

- [ ] **Step 2: 挂 CI**

在 `.gitea/workflows/pr-checks.yml` 的 `typecheck 作用面 gate (INFRA-F130)` step 之后追加：

```yaml
      - name: 文档断言 × 代码活源 gate (INFRA-F133)
        # 计划文件里的 prop / 导出名断言必须对得上 src/canonical 活源。
        # 判据（S1-S5）+ 结构化区域定义的真源在 scripts/audit-claim-vs-livesource.mjs 头注释
        # 与 spec §6.1（⛔ 别在这里维护第二份），闸每次运行自印覆盖面。
        # 纯 node、零 chromium、零 Figma 凭据，可上非特权 runner。
        # 直接调 node 而非 `pnpm run`：避开 `pnpm -- ` 的参数透传坑。
        # 与 pre-commit 同一条闸 —— INFRA-F61 gate 平权：拦 PR 的也拦 push:master。
        run: |
          FILES=$(git diff --name-only --diff-filter=AM origin/master...HEAD \
            | grep -E '^(docs/superpowers/plans|docs/internal/_prompts|docs/internal/_plans|docs/internal/_handoffs)/.*\.md$' \
            | paste -sd, -)
          node scripts/audit-claim-vs-livesource.mjs --files "$FILES"
```

- [ ] **Step 3: 验证 gate-ci-parity 不报红**

Run: `pnpm audit:gate-ci-parity`
Expected: EXIT=0。若报「pre-commit 有而 CI 无」，把两处的判据对齐后重跑。

- [ ] **Step 4: 跑校准（本计划唯一的未知量）**

对全部 34 份指令型文档跑一次，人工判前 30 条真假阳性：

```bash
ALL=$(ls docs/superpowers/plans/*.md docs/internal/_plans/*.md \
        docs/internal/_prompts/*.md docs/internal/_handoffs/*.md | paste -sd, -)
node scripts/audit-claim-vs-livesource.mjs --files "$ALL" 2>&1 | tee /tmp/f133-calibration.txt
```

判定：
- **假阳性率 < 10% 且 07-30 三条在真阳性里** → S2 保持阻塞档，进 Step 5
- **否则** → 按 spec §6.1「收窄档」把 `extractClaims` 收到**只认 `region === 'table'`**，重跑
- **收窄后仍 ≥ 10%** → ⛔ 执行 spec §9 的退档条件：S2 改成 report-only（与 S5 同档），**并在 commit message 与 backlog entry 里如实登记退档**。不许靠往 `claim-exempt.json` 里塞条目把红压绿 —— 那正是「靠豁免维持的假绿闸」。

- [ ] **Step 5: Commit**

```bash
git add .husky/pre-commit .gitea/workflows/pr-checks.yml
git commit -m "feat(gate): INFRA-F133 双挂 pre-commit + pr-checks —— Task 5

L4 + L5 同判据（audit:gate-ci-parity 硬要求）。只判 staged / PR-changed 的指令型 .md；
活源侧（canonical / barrel）或闸自身 staged 时也复跑。

校准结果：假阳性率 <实测值>%（判据面 <阻塞档 | 收窄档 | 退 report-only>）。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Task 6: 登记（AGENTS.md / meta-rules / backlog）

**Files:**
- Modify: `AGENTS.md`（硬规则 #10 的 Enforcement 列）
- Modify: `docs/meta-rules.md`（触发器 K）
- Modify: `docs/internal/backlog.md`（新增两条 entry）

**Interfaces:**
- Consumes: Task 5 的校准结论（决定登记的层级是 L4+L5 还是 report-only）
- Produces: 规则真源侧的层级登记

- [ ] **Step 1: 重新核号（⛔ 不要只信 `pnpm new-backlog`）**

```bash
git fetch origin
for f in docs/internal/backlog.md docs/STATUS.md \
         docs/internal/retrospection/design-spec-canonical-alignment-tracker.md; do
  git show "origin/master:$f" 2>/dev/null
done | grep -oE 'INFRA-F[0-9]+' | sed 's/INFRA-F//' | sort -n -u | tail -1
```

取 `max+1`、`max+2`、`max+3` 作本轮三个号。**若与 `F133`/`F134`/`F135` 不同，用实测值**，
并全局替换本计划、已提交的 spec、以及已落地代码注释里的号。

为什么不用 `pnpm new-backlog`：它只扫**本地** SoT，本地 behind 时会给出远端已占用的号
——本计划初稿的 `F132` 就是这么撞的（见 Global Constraints「取号」+ Step 4 第三条 entry）。

- [ ] **Step 2: 改 AGENTS.md 硬规则 #10 的 Enforcement 列**

按 spec §10 的清单：

1. 在 #10 的 Enforcement 列末尾追加一句（**正文不动，只动 Enforcement 列**）：

```
⚠️ 2026-08-20 新增一个此前未登记的形态：**计划文件里的具体值（prop / 导出名）未对活源核** —— 已上机械闸 `pnpm audit:claim-vs-livesource`（pre-commit 条件触发 + pr-checks；INFRA-F133）。⛔ 判据与结构化区域定义的真源是[闸脚本头注释](./scripts/audit-claim-vs-livesource.mjs)，**别在本表维护第二份**。⇒ 仍靠纪律的是：散文里的断言（S5 只印不红）、对话里的口头结论、以及**文档断言 × 另一份文档的当前版本**（本闸只核代码活源）。
```

2. 在形态 ①④ 后追加：

```
（2026-08-20 已评估：无可靠词法解 —— 实测 71 处引用中 48 处报红且多数假阳性，"当依据"与"当对象"是语义区分 ⇒ 维持 L1，不上闸）
```

⛔ **#9 一个字都不改**（spec §2 明确不做）。

- [ ] **Step 3: 改 docs/meta-rules.md 触发器 K**

在触发器 K 的层级谱系处补一条本闸的登记行，格式对齐既有条目：

```
- **enforcement 层级（触发器 K）**：**L4（pre-commit 条件 gate）+ L5（pr-checks）** —— `audit:claim-vs-livesource`（`scripts/audit-claim-vs-livesource.mjs`，2026-08-20 落地）机械核「指令型文档结构化区域里的 prop / 导出名断言 × canonical 活源」；仅当指令型 `.md` 或 canonical / barrel / 闸自身 staged 才跑。散文区域为 S5 report-only（语义不可判，实测 67% 假阳性）。
```

- [ ] **Step 4: 加三条 backlog entry**

用 Step 1 的号，把 stub 填成正式 entry 加到 `## Active`：

```markdown
### INFRA-F133: 文档断言 × 代码活源闸（已落地，留观察窗）

- **优先级**：Medium
- **发现时间**：2026-08-20
- **触发查看条件**：S5 report-only 的散文命中量积累一个月后，回看是否值得升格为阻塞档
- **阻塞关系**：独立
- **现状**：
  - 闸已上 L4+L5，覆盖结构化区域（表格同行 / 定义列表 / 字段块）
  - 散文区域为 S5 只印不红；对话口头结论与「文档断言 × 另一份文档当前版本」两个缺口仍 L1
- **建议路径**：
  - 攒够 S5 数据后判是否升格；不升格就如实留在 L1，别写第四份文字规则
```

第二条（Task 1 发现的既存问题）：

```markdown
### INFRA-F134: 三处 defineProps 解析实现待收敛到 lib

- **优先级**：Low
- **发现时间**：2026-08-20（INFRA-F133 Task 1 顺带发现）
- **触发查看条件**：下次要改任一处 props 解析逻辑时
- **阻塞关系**：独立
- **现状**：
  - `audit-binding-config-parity.mjs` / `audit-component-affordances.mjs` / `audit-framework-api-floor.mjs` 各写一套 defineProps 解析
  - `scripts/lib/canonical-props.mjs`（F133 新增）是第四份，但在正确落点
  - 正是 `scripts/lib/rule-ids.mjs` 头注释记录的同型病
- **建议路径**：
  - 把那三处改为 import lib。⚠️ 三条都在 L4/L5 活路径上，要连各自单测一起验；F133 当时刻意不动它们（重构活闸风险与收益不对等）
```

第三条（本计划撰写时踩到的工具缺口，见 Global Constraints「取号」）：

> 🔴 **2026-08-21 订正：本提案已作废，别照它立项。** ① 缺陷**已修** —— `new-backlog.mjs` 现扫「本地 ∪ `origin/master`」并集 + `--require-remote` 反转为 fail-closed（真源 = 该脚本头注释；留痕在 `backlog.md` INFRA-F105 那条「顺带修掉的一处」）② **`INFRA-F135` 这个号 2026-08-21 已被分配给另一件事**（STATUS 顶部摘要计数镜像无闸），下面这段草稿里的号是**过期的**。⇒ 真要立项一律走 `pnpm new-backlog`，⛔ 别从计划草稿里抄号。

```markdown
### INFRA-F135（⚠️ 号已作废，见上方订正）: `pnpm new-backlog` 只扫本地 SoT，behind 时会发出已被占用的号

- **优先级**：Medium
- **发现时间**：2026-08-20（INFRA-F133 计划撰写时实际踩到）
- **触发查看条件**：下次有人取号后发现撞号，或下次改 `scripts/new-backlog.mjs` 时
- **阻塞关系**：独立（与 `audit:rule-number-collision` 互补——那条是**检测**面，这条是**发号**面）
- **现状**：
  - `scripts/new-backlog.mjs` 扫 `docs/internal/backlog.md` / `docs/STATUS.md` / tracker 三份**本地**文件取 max+1
  - AGENTS.md 硬规则 #10 明确：判断全局命名空间当前状态，活源是 `origin/master`（先 `git fetch`）⇒ **工具与规则不一致**
  - 实测：本地 `[behind 2]` 时它给出 `INFRA-F132`，而该号在 `origin/master` 上已被 `5cd26e41` 占用
  - 撞号闸 `audit:rule-number-collision` 有 `--require-remote`，发号器**没有对应机制**
- **建议路径**：
  - 给 `new-backlog` 加 `--require-remote`（对齐撞号闸的既有形态）：先 `git fetch`，从 `origin/master` 读那三份 SoT 取 max
  - 或至少在本地 behind 时打印警告，不静默给号
```

⚠️ 三个号（`F133` / `F134` / `F135`）都要在 Step 1 重新核：**先 `git fetch`，再从 `origin/master` 的三份 SoT 取 max**，然后顺延三个。⛔ 别只跑 `pnpm new-backlog` —— 那正是第三条 entry 记的缺口。

- [ ] **Step 5: 全量核验 + Commit**

```bash
pnpm audit:stale-anchors && pnpm audit:rule-inventory && pnpm audit:doc-sync \
  && pnpm audit:doc-de-mirror && pnpm audit:status-consistency && pnpm audit:doc-shape \
  && pnpm audit:plan-lifecycle && pnpm audit:gate-ci-parity && pnpm test
```

全部 EXIT=0 后：

```bash
git add AGENTS.md docs/meta-rules.md docs/internal/backlog.md
git commit -m "docs(rules): INFRA-F133 层级登记 —— #10 一个形态升 L4+L5 —— Task 6

AGENTS.md #10 的 Enforcement 列追加新形态 + 闸名；形态 ①④ 标注'已评估无可靠词法解，维持 L1'。
判据真源指向闸脚本头注释，⛔ 不在硬规则表维护第二份（该条自己 2026-08-20 立的纪律）。
meta-rules 触发器 K 补本闸层级行。#9 一字未动。

新立 INFRA-F134（三处 defineProps 解析待收敛到 lib，F133 Task 1 顺带发现、刻意未动活闸）
与 INFRA-F135（new-backlog 只扫本地 SoT，behind 时会发已占用的号 —— 本计划初稿真撞了一次）。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>"
```

---

## Self-Review（本计划已自查）

**1. Spec 覆盖**

| spec 节 | 对应 Task |
|---|---|
| §5.1 分层 | Task 6（真源/登记）、Task 2（闸） |
| §5.2 数据流 | Task 2 `buildLiveSource` + Task 4 `main()` |
| §5.3 命名 | 全程用 `claim-vs-livesource` |
| §5.4 四个设计选择 | Task 2（import MANAGED_DIRS、不拦 Read）、Task 6（缺口登记） |
| §6 S1–S5 | S1/S3 → Task 2；S2/S4 → Task 3；S5 → Task 4 |
| §6.1 结构化区域两档 | Task 3（初始档）+ Task 5 Step 4（收窄档） |
| §7.1 文件清单 | File Structure 表逐项对应 |
| §7.2 存量策略 | Task 4 `--files` + Task 5 触发块 |
| §7.3 实现范式四约束 | Task 1/2（export + guard）、Task 4（exit 2）、Task 5（node 非 pnpm run） |
| §8 历史 bug 回归 | Task 3 Step 1 三条用例 |
| §9 校准与退档 | Task 5 Step 4 |
| §10 登记与缺口 | Task 6 Step 2/3 |

**2. Placeholder 扫描**：无 TBD / TODO / "similar to Task N"。所有代码步骤都给了完整代码。Task 5 Step 5 的 commit message 里 `<实测值>` 是**执行时才有的真实数据**，属于刻意留白而非占位符——它正是本闸要求的「不许把未核的数字写成事实」。

**3. 类型一致性**：`extractProps` / `collectCanonicalProps` / `buildLiveSource` / `evaluate` / `extractClaims` 的签名在 Task 1→4 间一致；failure code 命名统一为 `S<n>-<slug>`；豁免键统一 `${comp}::${ident}`。Task 4 里 `failures` 需带 `file` 字段，已在 Task 4 Step 3 末尾以 ⚠️ 显式点出要回填 Task 3 的 S2 循环。

**4. 一处已知的执行顺序依赖**：Task 3 的 S2 failure 不带 `file`（那时还没有多文件概念），Task 4 才引入。执行 Task 4 时必须回填，否则报错信息缺文件名。这是刻意的增量顺序，不是遗漏。
