# `audit:doc-shape` 段级形态契约闸 Implementation Plan

> ## ✅ 已于 2026-08-04（session X）执行完毕 —— 5 个 task 全部落地，commit `2c923bef`→`9f4ee831`
>
> **待做② 亦已于 2026-08-04（session Y）执行完毕**（受管三段降回规定形态、三条豁免由闸自宣布清空，叙述见 STATUS-CHANGELOG session Y 段）。**本文件保留只因 [[INFRA-F95]] 待做③ 未开工**，末尾「完成后的状态」段是它的交接口 —— ⛔ 那一段里对②的描述已过期，见下方就地标注。
> 逐字执行叙述（含 10 组故障注入、致败探针、实测值）在 [`STATUS-CHANGELOG.md`](../../internal/STATUS-CHANGELOG.md) 2026-08-04 session X 段。
>
> ### ⛔ 下面有三处**已被实测证伪**的指令，别照抄
>
> 1. **Task 2 Step 1 的「放过 entry 内部子项的划线」fixture 是错的** —— 那份压缩样本只有 ~80 字符，把 `✅` 拉进了 120 字符标题窗口**内**，与本计划自己给的实现直接矛盾；真仓库对应的条 5 长约 2 000 字符、`✅` 远在窗口外。**按它的 `expect` 改实现，等于给 `> N. **✅ 已完成** … ~~某子项~~` 开一条逃逸口。** 判据真源是 [spec §4.1](../specs/2026-08-03-infra-f95-doc-shape-enforcement-design.md)，已改为忠实样本 + 一条真仓库非空过钉。
> 2. **Task 3 Step 5 的 case 8 走错分支** —— 把豁免 `ceiling` 改到 `TOP_MAX` 以下时，实测值仍**高于** ceiling，命中的是「只许缩，不许涨」而非 `EXEMPT-STALE`。spec §5 那条（把顶部**内容**真降到 3 000 B 以下）才走得到自宣布路径，已按 spec 重跑验到。
> 3. **Task 4 Step 3 的 `git commit -F /dev/null --allow-empty-message` 会在 master 上留一个空 message 的 commit** —— 改为直接跑 `sh .husky/pre-commit`（真 staged 态，同样验到触发面正则 + 闸本体）；husky 端到端那一环由 Task 5 与收尾那两次**真 commit** 提供（两次 hook 输出里都有本闸的真实执行、不是 `skipped`）。
>
> ### ⚠️ 另两条落地时的实测偏差（不是错，是数漂了）
>
> - **三个豁免 `ceiling` 实测值全部已从本计划与 spec 的 08-03 快照漂移**：顶部 9 359 → **10 678 B** · Active 行 10 520 → **11 060 B** · S3a 5 → **6 条**（多的那条是 session W 刚闭合的 F96）。Global Constraints 里那句「⛔ 禁止照抄」是对的，照抄就会当天即红。
> - **Task 5 Step 2 那段测试是空过的**（只测原生 JS 数组过滤、不碰任何脚本，必然一上来就绿）→ 走了本计划自己给的正路：把 C4 计数抽成 `scripts/lib/backlog-open-entries.mjs` 的 `sliceActiveBody` / `countOpenEntries` 再测真函数。放 `scripts/lib/` 而不是把 `audit-status-consistency.mjs` 整体包一层 `main()`，是为了不让「改判据」这个 commit 变成 140 行缩进 diff。

> **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:** 把 `WRAP-UP.md:13`/`:18` 与 SoT 归属表「计数镜像」这三条既有 L1 规则升到 L4+L5，让「STATUS 顶部只放当日一条摘要 + 指针」「Active 段是计数镜像」「不保留已完成的任务」变成机械可拦的判据。

**Architecture:** 新增 `scripts/audit-doc-shape.mjs`，照抄 `scripts/audit-layout-tokens.mjs` 的形态——导出纯函数 + `evaluate({...})` + 常量（阈值、`EXEMPTIONS`），`main()` 只在被直接执行时跑。单测用 vitest 驱动 `evaluate`，另加对真仓库的「分母回归钉」。挂 pre-commit（条件触发）+ `prepublishOnly`（Gitea `pr-checks` 无 `paths` 过滤，自动跟随）。

**Tech Stack:** Node ESM（`.mjs`，仅 stdlib——`audit:scripts-stdlib` 闸会核）· vitest · husky pre-commit

**Spec:** [`specs/2026-08-03-infra-f95-doc-shape-enforcement-design.md`](../specs/2026-08-03-infra-f95-doc-shape-enforcement-design.md) · backlog [[INFRA-F95]] 待做 ①

## Global Constraints

- **字节 = UTF-8 字节，不是 JS 字符串长度**。全部用 `Buffer.byteLength(s, 'utf8')`。spec 里所有实测值都来自 `wc -c`（字节）；用 `String.length`（UTF-16 code unit）会让中文段少算约 2/3，阈值当场失去意义。
- **阈值**：`TOP_MAX = 3000` · `ACTIVE_LINE_MAX = 600`（spec §2，owner 已批 3000；600 是审查期按实测余量改的，推翻了原批的 400）。
- **⛔ `EXEMPTIONS` 的 `ceiling` 必须在 Task 3 落地那一刻 `wc -c` 现量，禁止照抄本计划或 spec 里的数。** 实证：2026-08-03 17:40→18:15 的 35 分钟里，Active 那行从 10 520 B 涨到 11 060 B。
- **只碰 `.md` / `.mjs` / `.ts` / `.json` / `.husky`**，不碰任何 `.css`/`.vue`（无需 `VISUAL_COMMIT_APPROVED`）。
- **commit 纪律**：`git commit -F <msgfile> -- <路径逐条内联>`，提交后紧跟 `git reset -- <路径>`；push 落地只信 `git ls-remote`。本仓库多 session 并行，不带 pathspec 会把别人 staged 的文件卷进来。
- **不拆、不移动任何规则文件** → `audit:rule-inventory` 的 S4/S6 硬编码扫描面无需变更（F95 ⛔④）。

---

## File Structure

| 文件 | 责任 |
|---|---|
| `scripts/audit-doc-shape.mjs`（新建） | 段切分 + 度量 + 三条判据 + 豁免 shrink-only 语义 + CLI |
| `tests/audit-doc-shape.test.ts`（新建） | 用合成输入驱动 `evaluate`；另加对真仓库的分母回归钉 |
| `package.json`（改） | 加 `audit:doc-shape` script；追加进 `prepublishOnly` 链尾 |
| `.husky/pre-commit`（改） | 条件触发块，紧跟既有的「STATUS 镜像一致性 gate」块之后 |
| `scripts/audit-status-consistency.mjs`（改，Task 5） | C4 移除「删除线 = 合法闭合」语义 |
| `docs/internal/backlog.md`（改，Task 5） | 真删 2 条已闭合 entry |

> **一处对 spec 的有意偏离，先说清楚**：spec §4.5 写「受管段定义集中在脚本顶部 `MANAGED_SECTIONS` 一张表」。本计划**不建那张表** —— 当前受管的只有 `STATUS.md` 的三个段，且三个段的**切分方式各不相同**（顶部按行范围、Active 按 fence、S3 按行正则），塞进一张统一的表只会让每个字段对三分之二的行没意义（反模式 #2「为 X 加特例」的镜像形态）。扩展点改为**脚本顶部的常量块**（`STATUS_FILE` / `BACKLOG_FILE` / `TOP_MAX` / `ACTIVE_LINE_MAX` / `EXEMPTIONS`）+ `sliceStatus` 一个函数：加新受管段 = 改这两处。若将来受管段涨到 ≥5 个且切分方式趋同，再抽表不迟。

---

### Task 1: 段切分与度量（纯函数）

**Files:**
- Create: `scripts/audit-doc-shape.mjs`
- Test: `tests/audit-doc-shape.test.ts`

**Interfaces:**
- Produces: `STATUS_FILE` / `BACKLOG_FILE` / `TOP_MAX` / `ACTIVE_LINE_MAX` 常量；`bytes(s)`；`sliceStatus(text)` → `{ top, openList, activeLines }`，其中 `top`/`openList` 是 string，`activeLines` 是 `{ label, text, bytes }[]`

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

```ts
// tests/audit-doc-shape.test.ts
import { describe, it, expect } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { bytes, sliceStatus, STATUS_FILE } from '../scripts/audit-doc-shape.mjs'

const REPO_ROOT = resolve(__dirname, '..')
const realStatus = () => readFileSync(resolve(REPO_ROOT, STATUS_FILE), 'utf-8')

const SAMPLE = [
  '# T',
  '',
  '> **Last updated**: 摘要',
  '>',
  '> ### 一 · 等 owner 拍板',
  '> 1. ~~已完成~~ —— ✅ 收口',
  '> 2. 还没做',
  '',
  '---',
  '',
  '## Active 后续工作',
  '',
  '```',
  'Backlog (active)  (2):  A·B',
  'Deferred (1): x',
  '```',
  '',
].join('\n')

describe('bytes', () => {
  it('按 UTF-8 字节算，不是 JS 字符串长度', () => {
    expect(bytes('中')).toBe(3)
    expect('中'.length).toBe(1) // 钉住这个陷阱：用 .length 会少算 2/3
  })
})

describe('sliceStatus', () => {
  it('顶部摘要区 = 文件头到首个 "> ### " 之前', () => {
    const s = sliceStatus(SAMPLE)
    expect(s.top).toContain('Last updated')
    expect(s.top).not.toContain('等 owner 拍板')
  })

  it('open 清单区 = 首个 "> ### " 到首个 "---"', () => {
    const s = sliceStatus(SAMPLE)
    expect(s.openList).toContain('等 owner 拍板')
    expect(s.openList).toContain('还没做')
    expect(s.openList).not.toContain('Active 后续工作')
  })

  it('Active fence 行带 label 与字节数', () => {
    const s = sliceStatus(SAMPLE)
    expect(s.activeLines.map((l) => l.label)).toEqual(['Backlog (active)', 'Deferred'])
    expect(s.activeLines[0].bytes).toBe(bytes('Backlog (active)  (2):  A·B'))
  })

  it('真仓库三段都非空（分母回归钉）', () => {
    const s = sliceStatus(realStatus())
    expect(bytes(s.top)).toBeGreaterThan(0)
    expect(bytes(s.openList)).toBeGreaterThan(0)
    expect(s.activeLines.length).toBeGreaterThan(0)
  })
})
```

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

Run: `pnpm vitest run tests/audit-doc-shape.test.ts`
Expected: FAIL — `Failed to load ../scripts/audit-doc-shape.mjs`

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

```js
// scripts/audit-doc-shape.mjs
import { readFileSync } from 'node:fs'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

export const STATUS_FILE = 'docs/STATUS.md'
export const BACKLOG_FILE = 'docs/internal/backlog.md'

/** spec §2：owner 已批 3000 */
export const TOP_MAX = 3000
/** spec §2.2：审查期实测最简合规行 321 B，400 只剩 79 B 余量 → 600 */
export const ACTIVE_LINE_MAX = 600

/** ⚠️ UTF-8 字节。用 String.length 会让中文段少算约 2/3。 */
export const bytes = (s) => Buffer.byteLength(s, 'utf8')

const OPEN_HEADING_RE = /^> ### /
const HR_RE = /^---\s*$/
const ACTIVE_HEADING_RE = /^## Active 后续工作\s*$/
const FENCE_RE = /^```/

/**
 * 把 STATUS.md 切成三段。任一段切不出来 → 返回 null 字段，由 evaluate 判 fail-closed。
 */
export function sliceStatus(text) {
  const lines = text.split('\n')

  const openStart = lines.findIndex((l) => OPEN_HEADING_RE.test(l))
  const top = openStart === -1 ? null : lines.slice(0, openStart).join('\n')

  let openList = null
  if (openStart !== -1) {
    const rel = lines.slice(openStart).findIndex((l) => HR_RE.test(l))
    if (rel !== -1) openList = lines.slice(openStart, openStart + rel).join('\n')
  }

  let activeLines = null
  const actHeading = lines.findIndex((l) => ACTIVE_HEADING_RE.test(l))
  if (actHeading !== -1) {
    const relOpen = lines.slice(actHeading).findIndex((l) => FENCE_RE.test(l))
    if (relOpen !== -1) {
      const bodyStart = actHeading + relOpen + 1
      const relClose = lines.slice(bodyStart).findIndex((l) => FENCE_RE.test(l))
      if (relClose !== -1) {
        activeLines = lines
          .slice(bodyStart, bodyStart + relClose)
          .filter((l) => l.trim() !== '')
          .map((l) => ({ label: (l.match(/^(.*?)\s*\(\d+\)\s*:/) || [, l])[1].trim(), text: l, bytes: bytes(l) }))
      }
    }
  }

  return { top, openList, activeLines }
}
```

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

Run: `pnpm vitest run tests/audit-doc-shape.test.ts`
Expected: PASS（5 个 it）

- [ ] **Step 5: Commit**

```bash
printf '%s\n' \
  'feat(doc-shape): 段切分与字节度量' '' \
  '按 UTF-8 字节算，不用 String.length —— 中文段会少算约 2/3，阈值当场失效。' '' \
  'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>' \
  > /tmp/m.txt
git add scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
git commit -F /tmp/m.txt -- scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
git reset -- scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
```

---

### Task 2: 三条判据 + 豁免 shrink-only 语义

**Files:**
- Modify: `scripts/audit-doc-shape.mjs`
- Modify: `tests/audit-doc-shape.test.ts`

**Interfaces:**
- Consumes: Task 1 的 `sliceStatus` / `bytes` / `TOP_MAX` / `ACTIVE_LINE_MAX`
- Produces: `findCompletedEntries(openListText)` → `{ code: 'S3a'|'S3b', line: string }[]`；`evaluate({ slices, exemptions })` → `{ failures: { code, message }[], report: {...} }`；`EXEMPTIONS` 常量

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

```ts
import {
  findCompletedEntries,
  evaluate,
  TOP_MAX,
  ACTIVE_LINE_MAX,
} from '../scripts/audit-doc-shape.mjs'

const slicesOf = (over) => ({
  top: 'x'.repeat(TOP_MAX - 100),
  openList: '> ### 一\n> 1. 还没做',
  activeLines: [{ label: 'Backlog (active)', text: 'y'.repeat(100), bytes: 100 }],
  ...over,
})

describe('findCompletedEntries', () => {
  it('S3a 命中标题被整条划掉', () => {
    const hits = findCompletedEntries('> 1. ~~已完成~~ —— ✅ 收口')
    expect(hits.map((h) => h.code)).toEqual(['S3a'])
  })

  it('S3b 命中标题段有 ✅ 但无划线', () => {
    const hits = findCompletedEntries('> 7. **✅ 已完成** 某事')
    expect(hits.map((h) => h.code)).toEqual(['S3b'])
  })

  it('放过 entry 内部子项的划线（仍 open 的条目）', () => {
    const line = '> 5. **[[INFRA-F87]]** 主体已 ship，此处只留残余②：~~冒烟闸只覆盖默认导出面~~ → 已闭合 ✅'
    expect(findCompletedEntries(line)).toEqual([])
  })

  it('✅ 出现在标题窗口之外不算 S3b', () => {
    // TITLE_WINDOW = 120 个**字符**（不是字节）—— repeat(40) = 160 字符，✅ 落在窗口外
    const line = '> 8. ' + '正常描述'.repeat(40) + ' ✅'
    expect(findCompletedEntries(line)).toEqual([])
  })

  it('✅ 落在标题窗口之内算 S3b（正向对照，防上一条空过）', () => {
    const line = '> 9. ' + '正常描述'.repeat(5) + ' ✅'
    expect(findCompletedEntries(line).map((h) => h.code)).toEqual(['S3b'])
  })
})

describe('evaluate', () => {
  it('顶部超阈值且无豁免 → S1 失败，且信息含搬迁目的地', () => {
    const { failures } = evaluate({ slices: slicesOf({ top: 'z'.repeat(TOP_MAX + 1) }), exemptions: [] })
    expect(failures.map((f) => f.code)).toContain('S1')
    expect(failures.find((f) => f.code === 'S1').message).toContain('STATUS-CHANGELOG')
  })

  it('豁免在位且未上涨 → 放行', () => {
    const top = 'z'.repeat(TOP_MAX + 500)
    const ex = [{ file: 'docs/STATUS.md', section: 'top-summary', code: 'S1', ceiling: TOP_MAX + 500, date: '2026-08-03', fix: '搬 CHANGELOG' }]
    expect(evaluate({ slices: slicesOf({ top }), exemptions: ex }).failures).toEqual([])
  })

  it('豁免在位但涨了 1 B → 失败（上涨即红）', () => {
    const top = 'z'.repeat(TOP_MAX + 501)
    const ex = [{ file: 'docs/STATUS.md', section: 'top-summary', code: 'S1', ceiling: TOP_MAX + 500, date: '2026-08-03', fix: '搬 CHANGELOG' }]
    const { failures } = evaluate({ slices: slicesOf({ top }), exemptions: ex })
    expect(failures.map((f) => f.code)).toContain('S1')
  })

  it('已降到阈值以下但豁免还挂着 → 失败并要求删豁免（修完由闸自己宣布）', () => {
    const ex = [{ file: 'docs/STATUS.md', section: 'top-summary', code: 'S1', ceiling: TOP_MAX + 500, date: '2026-08-03', fix: '搬 CHANGELOG' }]
    const { failures } = evaluate({ slices: slicesOf(), exemptions: ex })
    expect(failures.find((f) => f.code === 'EXEMPT-STALE').message).toContain('删除')
  })

  it('切不出段 → fail-closed，不是放行', () => {
    const { failures } = evaluate({ slices: { top: null, openList: null, activeLines: null }, exemptions: [] })
    expect(failures.map((f) => f.code)).toContain('SLICE')
    expect(failures.length).toBeGreaterThan(0)
  })

  it('受管段为 0 字节 → fail-closed（整体删掉 ≠ 合规）', () => {
    const { failures } = evaluate({ slices: slicesOf({ top: '' }), exemptions: [] })
    expect(failures.map((f) => f.code)).toContain('SLICE')
  })

  it('Active 行超阈值 → S2 点名那一行', () => {
    const lines = [{ label: 'Backlog (active)', text: 'y', bytes: ACTIVE_LINE_MAX + 1 }]
    const { failures } = evaluate({ slices: slicesOf({ activeLines: lines }), exemptions: [] })
    expect(failures.find((f) => f.code === 'S2').message).toContain('Backlog (active)')
  })
})
```

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

Run: `pnpm vitest run tests/audit-doc-shape.test.ts`
Expected: FAIL — `findCompletedEntries is not a function`

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

```js
// 追加到 scripts/audit-doc-shape.mjs

/** ⛔ ceiling 必须在落地当刻 wc -c 现量。占位值会让闸落地当天即红。 */
export const EXEMPTIONS = [
  // { file, section, code, ceiling, date, fix }  ← Task 3 填真实测值
]

const ENTRY_RE = /^> \d+\. /
const S3A_RE = /^> \d+\. ~~/
/** 标题窗口 = 120 个**字符**（不是字节）。spec §4.1 的正则 `.{0,120}` 也是字符语义；
 *  与 Global Constraints 里「字节」那条不冲突——那条管的是段体量，本常量管标题识别。 */
const TITLE_WINDOW = 120

/** 只看条目标题段。初版「整行含 ~~ 或 ✅」实测 9 命中 4 误报（entry 仍 open，划的是内部子项）。 */
export function findCompletedEntries(openListText) {
  const hits = []
  for (const line of openListText.split('\n')) {
    if (!ENTRY_RE.test(line)) continue
    if (S3A_RE.test(line)) { hits.push({ code: 'S3a', line }); continue }
    const head = line.slice(0, line.indexOf('. ') + 2 + TITLE_WINDOW)
    if (head.includes('✅')) hits.push({ code: 'S3b', line })
  }
  return hits
}

const DEST = {
  S1: 'docs/internal/STATUS-CHANGELOG.md 顶部',
  S2: 'docs/internal/backlog.md 各自 entry',
  S3: '各自的唯一真源（owner 2026-08-03 裁定：唯一真源、别处一律引用）',
}

export function evaluate({ slices, exemptions = EXEMPTIONS }) {
  const failures = []
  const push = (code, message) => failures.push({ code, message })

  // fail-closed：切不出段 / 段为空，一律不当 PASS
  if (slices.top === null || slices.openList === null || slices.activeLines === null) {
    push('SLICE', `${STATUS_FILE} 切不出受管段（顶部/open 清单/Active fence），拒绝放行`)
    return { failures, report: null }
  }
  if (bytes(slices.top) === 0 || bytes(slices.openList) === 0 || slices.activeLines.length === 0) {
    push('SLICE', `${STATUS_FILE} 受管段为空 —— 整体删掉不等于合规`)
    return { failures, report: null }
  }

  const actual = {
    S1: bytes(slices.top),
    S3a: findCompletedEntries(slices.openList).filter((h) => h.code === 'S3a').length,
  }
  const s3b = findCompletedEntries(slices.openList).filter((h) => h.code === 'S3b')

  const exOf = (code, section) =>
    exemptions.find((e) => e.code === code && (section === undefined || e.section === section))

  // ── S1
  const exS1 = exOf('S1', 'top-summary')
  if (exS1) {
    if (actual.S1 > exS1.ceiling) push('S1', `顶部摘要区 ${actual.S1} B > 豁免上界 ${exS1.ceiling} B（豁免只许缩，不许涨）`)
    else if (actual.S1 <= TOP_MAX) push('EXEMPT-STALE', `顶部摘要区已降到 ${actual.S1} B ≤ ${TOP_MAX} B —— 请删除 EXEMPTIONS 里 top-summary 那行`)
  } else if (actual.S1 > TOP_MAX) {
    push('S1', `顶部摘要区 ${actual.S1} B > 上限 ${TOP_MAX} B（超 ${actual.S1 - TOP_MAX} B）\n       规定形态：当日一条摘要 + 指针（WRAP-UP.md:13）\n       超出内容搬去：${DEST.S1}`)
  }

  // ── S2（逐行）
  for (const line of slices.activeLines) {
    const ex = exOf('S2', `active-line:${line.label}`)
    if (ex) {
      if (line.bytes > ex.ceiling) push('S2', `Active fence 行 "${line.label}" ${line.bytes} B > 豁免上界 ${ex.ceiling} B（只许缩）`)
      else if (line.bytes <= ACTIVE_LINE_MAX) push('EXEMPT-STALE', `Active fence 行 "${line.label}" 已降到 ${line.bytes} B —— 请删除对应豁免行`)
    } else if (line.bytes > ACTIVE_LINE_MAX) {
      push('S2', `Active fence 行 "${line.label}" ${line.bytes} B > 上限 ${ACTIVE_LINE_MAX} B\n       规定形态：计数镜像 = ID 列表 + 数字（WRAP-UP.md:18 / SoT 归属表）\n       超出内容搬去：${DEST.S2}`)
    }
  }

  // ── S3a（豁免 ceiling 是条数）
  const exS3 = exOf('S3a', 'completed-entries')
  if (exS3) {
    if (actual.S3a > exS3.ceiling) push('S3a', `open 清单区已完成条目 ${actual.S3a} 条 > 豁免上界 ${exS3.ceiling} 条（只许缩）`)
    else if (actual.S3a === 0) push('EXEMPT-STALE', 'open 清单区已无已完成条目 —— 请删除 completed-entries 豁免行')
  } else if (actual.S3a > 0) {
    push('S3a', `open 清单区有 ${actual.S3a} 条已完成条目仍挂着\n       owner 判据：不保留已完成的任务，加 ✅ 留着也不算合规\n       搬去：${DEST.S3}`)
  }

  // ── S3b 永不豁免
  for (const h of s3b) {
    push('S3b', `条目标题段标了 ✅ 但未删除：${h.line.slice(0, 60)}…\n       搬去：${DEST.S3}`)
  }

  // ── stale 豁免（指向已不存在的段）
  for (const e of exemptions) {
    if (e.code === 'S2' && !slices.activeLines.some((l) => `active-line:${l.label}` === e.section)) {
      push('EXEMPT-STALE', `豁免 ${e.section} 指向的 Active 行已不存在，请删除该行豁免`)
    }
  }

  return { failures, report: { ...actual, s3b: s3b.length, activeLines: slices.activeLines } }
}
```

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

Run: `pnpm vitest run tests/audit-doc-shape.test.ts`
Expected: PASS（Task 1 的 5 个 + 本任务 12 个）

- [ ] **Step 5: Commit**

```bash
printf '%s\n' \
  'feat(doc-shape): S1/S2/S3 判据 + shrink-only 豁免语义' '' \
  'S3 只看条目标题段：初版「整行含 ~~ 或 ✅」实测 9 命中里 4 条是误报' \
  '（entry 仍 open，划掉的是内部子项）。S3b 永不豁免——新实例立即红。' '' \
  'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>' \
  > /tmp/m.txt
git add scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
git commit -F /tmp/m.txt -- scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
git reset -- scripts/audit-doc-shape.mjs tests/audit-doc-shape.test.ts
```

---

### Task 3: CLI + 覆盖面自印 + 现量填豁免 + 故障注入实测

**Files:**
- Modify: `scripts/audit-doc-shape.mjs`（加 `main()`）
- Modify: `package.json`（加 script）

**Interfaces:**
- Consumes: Task 2 的 `evaluate` / `EXEMPTIONS`
- Produces: `pnpm run audit:doc-shape`，exit `0` PASS / `1` FAIL

- [ ] **Step 1: 加 `main()` 与参数拒绝**

```js
// 追加到 scripts/audit-doc-shape.mjs
const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..')

export function main(argv = process.argv.slice(2)) {
  // 空/畸形输入 fail closed：`pnpm run x -- --foo` 会把参数透传进来
  if (argv.length > 0) {
    console.error(`❌ audit:doc-shape 不接受任何参数，收到：${argv.join(' ')}`)
    process.exit(1)
  }

  let text
  try {
    text = readFileSync(resolve(REPO_ROOT, STATUS_FILE), 'utf-8')
  } catch (err) {
    console.error(`❌ audit:doc-shape 读不到 ${STATUS_FILE}：${err.message}`)
    process.exit(1)
  }

  const slices = sliceStatus(text)
  const { failures, report } = evaluate({ slices, exemptions: EXEMPTIONS })

  // 覆盖面自印（F87 教训：别据此宣称「已守住」）
  console.log('audit:doc-shape — 覆盖面（别据此宣称「文档体量已受控」）')
  console.log(`  ${STATUS_FILE}：S1 顶部 / S2 Active 行 / S3 完成叙述 —— 三条阻塞判据`)
  console.log(`  ${BACKLOG_FILE}：只有 S4 report-only，无任何阻塞判据 —— 本闸不守 backlog 体量`)
  if (report) {
    console.log(`  S1 顶部摘要区 : ${report.S1} B（上限 ${TOP_MAX}）`)
    for (const l of report.activeLines) console.log(`  S2 "${l.label}" : ${l.bytes} B（上限 ${ACTIVE_LINE_MAX}）`)
    console.log(`  S3 已完成条目 : S3a ${report.S3a} 条 · S3b ${report.s3b} 条`)
  }
  try {
    console.log(`  S4 体量（report-only）: STATUS ${bytes(text)} B · backlog ${bytes(readFileSync(resolve(REPO_ROOT, BACKLOG_FILE), 'utf-8'))} B`)
  } catch { /* backlog 缺失不阻塞 report-only */ }
  console.log(`  豁免        : ${EXEMPTIONS.length} 条`)

  if (failures.length) {
    console.error(`\n❌ audit:doc-shape FAIL：${failures.length} 条违例`)
    for (const f of failures) console.error(`  [${f.code}] ${f.message}`)
    console.error('\n  真源形态：WRAP-UP.md:13（顶部只放当日一条摘要 + 指针）/ :18（Active 段刷新剩余项数）')
    console.error('  维护：scripts/audit-doc-shape.mjs（INFRA-F95）')
    process.exit(1)
  }
  console.log('\n✓ audit:doc-shape PASS')
  process.exit(0)
}

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

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

在 `"audit:status-consistency"` 那行之后加：

```json
"audit:doc-shape": "node scripts/audit-doc-shape.mjs",
```

- [ ] **Step 3: 现量三个 ceiling，填进 `EXEMPTIONS`**

⛔ 不要用本计划里的数。跑：

```bash
awk '/^> ### /{exit} {print}' docs/STATUS.md | wc -c   # → S1 ceiling
pnpm run audit:doc-shape                                # 输出里读 S2 各行字节与 S3a 条数
```

按实测填（`ceiling` 用刚量到的值，`date` 用真实系统日期）：

```js
export const EXEMPTIONS = [
  { file: 'docs/STATUS.md', section: 'top-summary', code: 'S1', ceiling: /* 实测 */ 0, date: '2026-08-03',
    fix: '<details> 归档块搬 STATUS-CHANGELOG；三条「已归档」指针并成一条；行25 编辑判据与行27 并行纪律搬 AGENTS.md' },
  { file: 'docs/STATUS.md', section: 'active-line:Backlog (active)', code: 'S2', ceiling: /* 实测 */ 0, date: '2026-08-03',
    fix: '降回 ID 列表 + 数字，叙述回各自 backlog entry' },
  { file: 'docs/STATUS.md', section: 'completed-entries', code: 'S3a', ceiling: /* 实测条数 */ 0, date: '2026-08-03',
    fix: '按 owner「唯一真源、别处引用」裁定各归各家' },
]
```

- [ ] **Step 4: 阴性对照——不改任何文件应 PASS**

Run: `pnpm run audit:doc-shape`
Expected: exit 0，且覆盖面那几行印出真实字节数

- [ ] **Step 5: 造故障，逐条看它变红**

每条**先确认故障态成立**（改完 `git diff` 看到那处改动），再跑闸；跑完 `git checkout -- docs/STATUS.md` 还原。

| # | 注入 | 期望 |
|---|---|---|
| 1 | 顶部 blockquote 塞 1 KB 文字 | exit 1，`[S1]` 且含 `STATUS-CHANGELOG` |
| 2 | 把 `Deferred` 那行加到 700 B | exit 1，`[S2]` 点名 `Deferred` |
| 3a | open 清单区加一条 `> 99. ~~测试~~ —— ✅` | exit 1，`[S3a]`（存量已豁免，多一条即红） |
| 3b | 加一条 `> 99. **✅ 已完成** 测试` | exit 1，`[S3b]` —— **这条不造就无法排除 S3b 是空判据** |
| 3c | 在条 5 那种仍 open 的条目**内部**再加 `~~子项~~` | **仍 exit 0**（证明没退回初版误报） |
| 4 | 删掉 `## Active 后续工作` 整行 | exit 1，`[SLICE]`，**不是 PASS** |
| 5 | `: > docs/STATUS.md` 置空 | exit 1，`[SLICE]` |
| 6 | `pnpm run audit:doc-shape -- --foo` | exit 1，「不接受任何参数」 |
| 7 | 把 `top-summary` 的 `ceiling` 手改小 1 | exit 1，`[S1]`「只许缩，不许涨」 |
| 8 | 把 `ceiling` 手改到 `TOP_MAX` 以下 | exit 1，`[EXEMPT-STALE]`「请删除…那行」 |

- [ ] **Step 6: Commit**

```bash
printf '%s\n' \
  'feat(doc-shape): CLI + 覆盖面自印 + 九组故障注入实测' '' \
  '闸自印「backlog 只有 report-only、本闸不守 backlog 体量」——别据此' \
  '宣称文档体量已受控（F87 教训）。豁免 ceiling 为落地当刻现量。' '' \
  'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>' \
  > /tmp/m.txt
git add scripts/audit-doc-shape.mjs package.json
git commit -F /tmp/m.txt -- scripts/audit-doc-shape.mjs package.json
git reset -- scripts/audit-doc-shape.mjs package.json
```

---

### Task 4: 上闸（L4 + L5）——并验证它**真的被触发**

**Files:**
- Modify: `.husky/pre-commit`
- Modify: `package.json`（`prepublishOnly` 链）

**Interfaces:**
- Consumes: Task 3 的 `pnpm run audit:doc-shape`

> **本任务是整条 plan 最不能省的一环。** F95 的病根就是「规则写了、没人执行」（`WRAP-UP.md:13` 写于 2026-06-09，三周内 4 次精简全被填平）。闸写好但没真挂上 = 同一个病换形态复发（meta-rules 反模式 #7：声明 ≠ 被消费）。

- [ ] **Step 1: 加 pre-commit 条件触发块**

在 `.husky/pre-commit` 里「STATUS 镜像一致性 gate (C4 …)」那个块之后追加：

```bash
echo "▶ pre-commit: 文档段级形态契约 gate (INFRA-F95)"
# 治的病 = STATUS 顶部与 Active 段的形态规则（WRAP-UP.md:13/:18）早在 2026-06-09
# 就写下了，但纯 L1、无人执行：3 周内 4 次手动精简全部在 2-4 天内被填平，且增量
# 全长在既有行内部（行数几乎不动 → 看 diff 行数完全正常）。本闸把那两条规则升到 L4。
# ⚠️ backlog.md 只被 S4 report-only 观测，无阻塞判据 —— 别因本闸绿就宣称体量已受控。
if git diff --cached --name-only --diff-filter=AMD | grep -qE '(^docs/STATUS\.md$|^docs/internal/backlog\.md$|^scripts/audit-doc-shape\.mjs$)'; then
  pnpm run audit:doc-shape
else
  echo "   ✓ no STATUS/backlog/gate files staged, skipped"
fi
```

- [ ] **Step 2: 追加进 `prepublishOnly`**

在 `package.json` 的 `prepublishOnly` 里 `audit:status-consistency` 之后插入 `&& pnpm run audit:doc-shape`。（Gitea `pr-checks.yml` 无 `paths` 过滤，跑同一条链，自动跟随。）

- [ ] **Step 3: 验收 10 —— 证明闸真的挂上了**

```bash
touch docs/STATUS.md && git add docs/STATUS.md
git commit -F /dev/null --allow-empty-message -- docs/STATUS.md 2>&1 | grep -A2 'INFRA-F95'
git reset -- docs/STATUS.md
```

Expected: 输出里出现 `▶ pre-commit: 文档段级形态契约 gate (INFRA-F95)` **且其后是闸的真实输出**，不是 `skipped`。
⚠️ 只看到 `skipped` = 触发面正则写错，**必须回 Step 1 修**。

- [ ] **Step 4: 验收 11 —— 无关文件应跳过**

```bash
touch README.md && git add README.md
git commit -F /dev/null --allow-empty-message -- README.md 2>&1 | grep -A2 'INFRA-F95'
git reset -- README.md
```

Expected: `✓ no STATUS/backlog/gate files staged, skipped`

- [ ] **Step 5: 全链自检**

Run: `pnpm run audit:status-consistency && pnpm run audit:rule-inventory && pnpm run audit:doc-shape && pnpm vitest run tests/audit-doc-shape.test.ts`
Expected: 四条全 exit 0

- [ ] **Step 6: Commit**

```bash
printf '%s\n' \
  'feat(doc-shape): 上 L4 pre-commit + L5 prepublishOnly' '' \
  '实测确认闸真的被触发（staged STATUS.md 时 hook 输出里有它的真实输出，' \
  '不是 skipped）——F95 的病根就是规则写了没人执行，闸没挂上等于复发。' '' \
  'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>' \
  > /tmp/m.txt
git add .husky/pre-commit package.json
git commit -F /tmp/m.txt -- .husky/pre-commit package.json
git reset -- .husky/pre-commit package.json
```

---

### Task 5: C4 闭合约定改「完成即删档」（spec §6.2 owner 批准 · 三步原子）

**Files:**
- Modify: `docs/internal/backlog.md`（删 2 条已闭合 entry）
- Modify: `scripts/audit-status-consistency.mjs:105-140`（C4）
- Modify: `docs/STATUS.md`（行 132 数字，仅当实测变化时）

> **⚠️ 顺序不可交换，且必须在一个 commit 里落。** 现状实测（2026-08-03 18:15）：`## Active` 共 **32** 个 `### ` heading，其中 **2** 条带删除线 → open **30**，STATUS 声明 **30** ✓ 绿。
> 若先改 C4 语义而不删 entry：open 变 **32** ≠ 声明 30 → **master 上出现红闸窗口**。
> 先删那 2 条 entry 后：旧公式 `32-2-2+0 = 30`、新公式 `30` —— **两个公式给同一个数**，所以改 C4 是无缝的，STATUS 数字通常无需动。
> ⛔ 这些数**每工作日都在漂**，实施时先重量：`awk '/^## Active/,0' docs/internal/backlog.md | grep -cE '^### '`

- [ ] **Step 1: 重量基线并确认哪几条是删除线 entry**

```bash
awk '/^## Active/,0' docs/internal/backlog.md | grep -cE '^### '     # 总 heading
awk '/^## Active/,0' docs/internal/backlog.md | grep -cE '^### ~~'   # 删除线
grep -nE '^### ~~' docs/internal/backlog.md                          # 是哪几条
grep -oE 'Backlog \(active\) +\([0-9]+\)' docs/STATUS.md             # STATUS 声明
```

2026-08-03 实测那 2 条 = `INFRA-F88`（视觉闸覆盖面，已收口）· `INFRA-F89`（`:3001` 网页 UI，已修复）。**删前逐条确认 entry 正文里的常驻约束已在别处有真源**——F88 的「动闸前必读 spec §5b 两条实现期陷阱」与 F89 的「`:3001` 现在是 `gitea-proxy`」都已在 STATUS §一 4 / §一 3 与 `DEPLOY.md` 里，删 entry 不丢信息。若发现某条约束**只**活在待删 entry 里，先把它搬到真源再删。

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

```ts
// tests/audit-status-consistency.test.ts（若不存在则新建；存在则追加）
import { describe, it, expect } from 'vitest'

describe('C4 闭合约定 = 完成即删档', () => {
  it('删除线 heading 不再被当作合法闭合形态', () => {
    const body = '### A: x\n### ~~B: y~~ → 已完成\n'
    const headings = body.split('\n').filter((l) => l.startsWith('### '))
    // 新语义：open = 全部 heading（删除线不再豁免）
    expect(headings.length).toBe(2)
  })
})
```

> 若 `audit-status-consistency.mjs` 当前未导出可测函数，本步先把 C4 的计数逻辑抽成 `export function countOpenEntries(activeBody)` 再写测试——**不要为了可测性改判据本身**。

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

Run: `pnpm vitest run tests/audit-status-consistency.test.ts`
Expected: FAIL（旧实现仍减去删除线）

- [ ] **Step 4: 按顺序落三处改动**

1. 删掉 `docs/internal/backlog.md` 里那 2 条 `### ~~…~~` entry 的**整段正文**（heading 到下一个 `### ` 之前）
2. 改 `scripts/audit-status-consistency.mjs`：`const open = headings.filter(l => !l.startsWith('### ~~')).length` → `const open = headings.length`；同步改注释与 C4 的 FAIL 文案（原文「闭合 = heading 加 `~~删除线~~`」→「闭合 = 直接删除 entry（owner 2026-08-03 裁定）」）
3. 重跑 `pnpm run audit:status-consistency`；**仅当**它报数字不符时才改 STATUS 行 132

- [ ] **Step 5: 致败探针——证明顺序真的不可交换**

```bash
git stash push docs/internal/backlog.md        # 只回退「删 entry」，保留 C4 新语义
pnpm run audit:status-consistency; echo "exit=$?"   # 期望 exit=1（open 32 ≠ 声明 30）
git stash pop
pnpm run audit:status-consistency; echo "exit=$?"   # 期望 exit=0
```

Expected: 先 `exit=1` 后 `exit=0`。**若第一次就是 0，说明 C4 改动没生效**（判据没真被替换），回 Step 4。

- [ ] **Step 6: 全链复跑 + Commit**

Run: `pnpm run audit:status-consistency && pnpm run audit:doc-shape && pnpm vitest run tests/`

```bash
printf '%s\n' \
  'refactor(c4): backlog 闭合约定改「完成即删档」，删除线不再算合法闭合' '' \
  'owner 2026-08-03 裁定：Backlog 和 Status 都不保留已完成的任务，加 ✅' \
  '留着也不算合规。而 C4 此前把「heading 加删除线」编码成合法闭合形态——' \
  '两者已经冲突，只是没人发现。' '' \
  '三处改动顺序不可交换：先删 entry 再改判据，否则 open 会从 30 跳到 32、' \
  '与 STATUS 声明对不上，在 master 上留一个红闸窗口。致败探针已实测。' '' \
  'Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>' \
  > /tmp/m.txt
git add docs/internal/backlog.md scripts/audit-status-consistency.mjs tests/audit-status-consistency.test.ts docs/STATUS.md
git commit -F /tmp/m.txt -- docs/internal/backlog.md scripts/audit-status-consistency.mjs tests/audit-status-consistency.test.ts docs/STATUS.md
git reset -- docs/internal/backlog.md scripts/audit-status-consistency.mjs tests/audit-status-consistency.test.ts docs/STATUS.md
git push origin master && git ls-remote origin master
```

---

## 完成后的状态（给下一个接手人）

- 三条 L1 规则升到 L4+L5。~~豁免表 3 条具名带日期 shrink-only~~ → **2026-08-04（session Y）三条已全部由闸打 `EXEMPT-STALE` 要求删行并删除，`EXEMPTIONS = []` 现在是空的终态**
- ~~**F95 待做 ② 尚未开工**~~ → **✅ 2026-08-04 session Y 已执行完毕**（顶部 10 346 → 1 630 B · Active 行 11 060 → 378 B · S3a 6 → 0 条 · STATUS 全文 −42%；4 处漂移逐条修完，含 [[INFRA-F77]] 删档）。⛔ **别再照本行原先那句去「做待做②」** —— 那一次性清零额度已经用掉，此后受管段回涨由闸拦，不该再发起第 5 次手动精简。逐字叙述见 STATUS-CHANGELOG session Y 段
- **F95 待做 ③ 未开工**：`docs/` 278 份 / 6.11 MB 的三大件判定，独立 scope
- ⛔ 别因本闸绿就宣称「文档体量已受控」——backlog 侧 209 KB、日增 32.5 KB 那一面**只被观测、无人守**
