// tests/audit-rule-inventory-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-rule-inventory.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 选这条（20 条「非随包 ∧ blocking ∧ 接线零覆盖 ∧ P2 干净 ∧ P3 ∈ {NAMED,DIRLIST}」
//   候选里的排序理由，⛔ 数别抄 —— 跑 `pnpm report:gate-regression-face` 自己算）：
//   **候选集里挂载最深的一条 —— L4 pre-commit + L5 gh-ci + L5 gate-chain 三层**
//   （`.husky/pre-commit` · `.github/workflows/ci.yml` 走 `prepublishOnly` ·
//   `package.json` 的 `gate-chain`）。且它守的东西是**每个 AI session 起手都要吃的那份**：
//   规则清单的唯一真源（S1-S3/S5）与「谁有资格声明起手必读链路」（S4/S6）。
//   它假绿的后果不是某个组件画错，是**所有工具按一份漂了的清单 scoped-load**
//   —— 正是闸头注释里 2026-08-03 实测的「5 处各枚举一套、内容 4 种、全部 stale」。
//
// ⛔ 与 `tests/audit-rule-inventory.test.ts` 的分工（**别合并**）：那份 import
//   `extractHeadingIds` / `extractInventory` / `findEnumerationMirrors` /
//   `findSelfDeclaredChains` / `evaluate` 五个纯函数，覆盖**判据逻辑**（40 条）。
//   本份 spawn 整个脚本，覆盖只有整脚本能看见的六样：
//     · 接线（入口守卫 → `main()` → `process.exit(0)` / `process.exit(1)` 两个出口）
//     · `readOrNull()`：目标文件读不到时走 **S1** 而不是 skip（fail closed 的实际路径）
//     · `INVENTORIES` 的 **mode 分派** —— `mode:'rule-ids'` 那两份真的走
//       `extractRuleInventoryIds` 而不是 `extractHeadingIds`。⚠️ 纯函数那份**根本没 import**
//       `extractRuleInventoryIds`，且它那条「真仓库分母非空」的回归钉对四份**一律**用
//       `extractHeadingIds(read(target), inv.level)` ⇒ 对这个分派结构上零敏感。
//     · `MIRROR_SCAN_DOCS` / `CHAIN_SCAN_DOCS` 两张注册表真的被逐份消费（退化成「只扫第一份」
//       不会被纯函数测试发现），以及 `mirrorFileCount++` 的存在性探测
//     · `doc === inv.target` 那句 `continue`：目标文件自己头部的清单不算「别处又枚举一遍」
//     · 覆盖面自印（`audit:rule-inventory — 覆盖面` 那块）在**红档也照印**
//
// ⛔ 本面**覆盖不到 S5**（豁免 shrink-only）：`EXEMPTIONS` 是模块顶层常量、且现为空表，
//   从 fixture 侧无法在**不改闸**的前提下让它非空（[[INFRA-F138]] 逐字封的路：
//   「别为了让 harness 能用去改活闸」）。S5 的判据逻辑由纯函数那份的两条用例覆盖，
//   本面只钉它的自印读数（`豁免 : 0 条（命中 0）`）。**这是如实登记的边界，不是待补 TODO。**
//
// ⛔ 污染纪律（[[INFRA-F138]] 记的三种形态）：本文件除被测闸自身外**不出现任何别的闸的
//   `.mjs` basename、也不出现任何别的 `audit:` npm key** —— 否则量具会把那条闸误报成
//   「已覆盖」。本闸的 stdout 只引用它自己（`维护：scripts/audit-rule-inventory.mjs`），
//   所以这里不需要断言截短。fixture 里的 id 一律用 `FX-` / `-FX` 假名。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-rule-inventory.mjs'

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 四份目标文件的分母全部挑成**真仓库不可能撞上**的（真仓库现取：design-process 25 个 H2 ·
// domain-tvu 12 · mockup-conventions 67 rule heading · code-conventions 21 · S4 面 12/12 ·
// S6 面 6/6）—— 绿档钉的就是这几个数，反向排除的也是它们。

/** level 模式（H2 = 一条规则）的两份。 */
const DP_IDS = ['FX-DP-ONE', 'FX-DP-TWO', 'FX-DP-THREE']
const DT_IDS = ['FX-DT-ONE', 'FX-DT-TWO', 'FX-DT-THREE', 'FX-DT-FOUR']

/** rule-ids 模式的两份。id 形态取自 `scripts/lib/rule-ids.mjs` 的 `RULE_HEADING_RE`。 */
const MC_IDS = ['M-FXMA', 'M-FXMB', 'M-FXMC', 'R-FXMD', 'R-FXME']
const CC_H2_IDS = ['M-FXCA', 'M-FXCB', 'M-FXCC']
const CC_H3_IDS = ['R-FXCD', 'R-FXCE', 'R-FXCF']
const CC_IDS = [...CC_H2_IDS, ...CC_H3_IDS]

/**
 * inventory 标记块。⚠️ id 行必须含 **≥2 个 `/`** 才会被 `extractInventory` 取中
 * （闸刻意如此：标签行里的单个斜杠不该被误当 id 行）⇒ 每份清单至少 3 个 id。
 */
function inventoryBlock(markerId: string, ids: string[]): string {
  return [
    `<!-- rule-inventory:${markerId} -->`,
    '> **本文件规则清单** —— fixture 专用，scoped 加载按本清单挑。',
    '>',
    `> ${ids.join(' / ')}`,
    '<!-- /rule-inventory -->',
  ].join('\n')
}

type DocOpts = {
  /** 活源里真实存在的 id */
  ids?: string[]
  /** inventory 块里登记的 id（默认 = ids） */
  listed?: string[]
  /** 标记块的 marker id；`null` = 整块不写 */
  marker?: string | null
  /** 追加到正文的额外行 */
  extra?: string[]
}

function designProcess(o: DocOpts = {}): string {
  const ids = o.ids ?? DP_IDS
  const marker = o.marker === undefined ? 'design-process' : o.marker
  return [
    '# Fixture Design Process',
    '',
    ...(marker === null ? [] : [inventoryBlock(marker, o.listed ?? ids), '']),
    ...(o.extra ?? []),
    '',
    ...ids.map((id) => `## ${id} — fixture 条目`),
    '',
    // H3 子条：level=2 不收 ⇒ 本文件「看起来 4 个标题、活源 3 个」。
    // 顺带是 S4 整词边界的天然锚点：`FX-DP-ONE.1` 里的 `.` 在 `[\w.-]` 里 ⇒ 不被计为 `FX-DP-ONE`。
    '### FX-DP-ONE.1 — H3 子条',
    '',
  ].join('\n')
}

function domainTvu(o: DocOpts = {}): string {
  const ids = o.ids ?? DT_IDS
  return [
    '# Fixture Domain TVU',
    '',
    inventoryBlock('domain-tvu', o.listed ?? ids),
    '',
    ...ids.map((id) => `## ${id} — fixture 条目`),
    '',
  ].join('\n')
}

/**
 * rule-ids 模式 ①：**多**两个非规则 H2 ⇒ 若分派退化成 level 模式，分母会是 7 而不是 5。
 */
function mockupConventions(o: DocOpts = {}): string {
  const ids = o.ids ?? MC_IDS
  return [
    '# Fixture Mockup Conventions',
    '',
    inventoryBlock('mockup-conventions', o.listed ?? ids),
    '',
    '## 概述',
    '',
    '## 附录',
    '',
    ...ids.map((id) => `## ${id} — fixture 规则`),
    '',
  ].join('\n')
}

/**
 * rule-ids 模式 ②：一半规则挂在 **H3** 上 ⇒ 若分派退化成 level 模式，分母会是 3 而不是 6。
 * 与 ① 是**反方向**的两个钉：一个多报、一个少报，两边都能抓住分派错。
 */
function codeConventions(o: DocOpts = {}): string {
  const h2 = o.ids ? o.ids.slice(0, 3) : CC_H2_IDS
  const h3 = o.ids ? o.ids.slice(3) : CC_H3_IDS
  return [
    '# Fixture Code Conventions',
    '',
    inventoryBlock('code-conventions', o.listed ?? [...h2, ...h3]),
    '',
    ...h2.map((id) => `## ${id} — fixture 规则`),
    '',
    ...h3.map((id) => `### ${id} — fixture 规则`),
    '',
  ].join('\n')
}

/** 干净的 AGENTS.md：纯指针，零 id、零列表结构 ⇒ S4 / S6 都不该命中。 */
const CLEAN_AGENTS = [
  '# Fixture AGENTS',
  '',
  '| 文件 | 说明 |',
  '|---|---|',
  '| `docs/internal/design-process.md` | 通用 process 规则（清单见该文件头的标记块）|',
  '| `docs/internal/domain-tvu.md` | 业务规则（同上）|',
  '',
].join('\n')

/** 干净的 session-handoff：只有指针句，没有列表 ⇒ S6 不该命中。 */
const CLEAN_HANDOFF = [
  '# Fixture Session Handoff',
  '',
  '本文件不复述清单：起手链路真源见 docs/STATUS.md，这里只写本次任务增量。',
  '',
].join('\n')

/** S4 违例行：同一行既提目标文件 basename，又裸列 3 个已知 id。 */
const MIRROR_LINE = `| \`docs/internal/design-process.md\` | 通用 process 规则（${DP_IDS.join(' / ')}）|`

/** S4 的**阈值下方**对照行：同样提 basename，但只列 2 个 id ⇒ 是引用不是清单。 */
const MIRROR_LINE_2IDS = `| \`docs/internal/design-process.md\` | 见 ${DP_IDS.slice(0, 2).join(' / ')} 两条 |`

/** S6 违例块：framing 关键词 + 一段连续列表项里 ≥3 份不同 .md。 */
const CHAIN_BLOCK = [
  '## 收到唤醒词后的标准动作',
  '',
  '1. 先阅读：',
  '   - `docs/fx-chain-one.md`',
  '   - `docs/fx-chain-two.md`',
  '   - `docs/fx-chain-three.md`',
  '',
].join('\n')

/** S6 报文里的路径是 **basename 归一后按字典序排** 的 —— 这串是 fixture 独有读数。 */
const CHAIN_PATHS = '3 份：fx-chain-one.md / fx-chain-three.md / fx-chain-two.md'

/**
 * S7 受管「已指针化」文档的**干净版**：概念层散文 + 指针，零 id 枚举。
 *
 * ⚠️ **这四份必须进 fixture 树**：S7 对读不到的受管文件是 fail closed（不是 skip）⇒
 * 少一份就多一条红，A-E 全组的违例计数会整体偏移。2026-09-03 S7 落地当天正是这么炸的
 * —— plan 里从头到尾没提过本文件的存在，它对「闸会不会红」的预判又错了一次。
 * ⛔ 修法是把 fixture 补齐，**不是**让 S7 对缺文件 skip（那会把 fail-closed 判据挖空）。
 */
const CLEAN_POINTERIZED = [
  '# Fixture Pointerized Doc',
  '',
  '本文件不复述规则清单：清单活源在各 conventions 文件自己头部的标记块，这里只放指针。',
  '',
].join('\n')

/** S7 违例素材 ①（inline）：单行 ≥3 个裸 id。id 用 `-FX` 假名，真仓库不可能撞上。 */
const S7_INLINE_LINE = '本文件规则含 M-FXPA / M-FXPB / M-FXPC 三条。'

/** S7 违例素材 ②（table）：连续 3 行首列以 id 打头 —— 每行只 1 个 id，正是 S4 扫不到的形态。 */
const S7_TABLE_BLOCK = ['| M-FXPA | 甲 |', '| M-FXPB | 乙 |', '| M-FXPC | 丙 |'].join('\n')

type FixtureOpts = {
  designProcess?: string
  domainTvu?: string
  mockupConventions?: string
  codeConventions?: string
  agents?: string
  sessionHandoff?: string
  /** S7 受管面：导览文档正文（默认干净版） */
  overview?: string
  /** S7 受管面：mockup skill 正文（默认干净版） */
  skillMockup?: string
  /** 从 fixture 里拿掉这些文件（造「读不到」用） */
  omit?: string[]
}

/**
 * 一棵干净的 fixture 树：exit 0。
 * ⛔ `linkDirs` 用默认（软链 `scripts/lib`）—— 闸 import `./lib/rule-ids.mjs`，
 *    传 `[]` 会让它 ENOENT 崩掉，而**崩溃也是非零退出** ⇒ 红得理由不对。
 */
function fixture(o: FixtureOpts = {}): string {
  const files: Record<string, string> = {
    'docs/internal/design-process.md': o.designProcess ?? designProcess(),
    'docs/internal/domain-tvu.md': o.domainTvu ?? domainTvu(),
    'docs/internal/mockup-conventions.md': o.mockupConventions ?? mockupConventions(),
    'docs/internal/code-conventions.md': o.codeConventions ?? codeConventions(),
    'AGENTS.md': o.agents ?? CLEAN_AGENTS,
    'docs/session-handoff.md': o.sessionHandoff ?? CLEAN_HANDOFF,
    // S7 受管面（POINTERIZED_DOCS）—— 见 CLEAN_POINTERIZED 上方的 ⚠️：缺一份就多一条红
    'docs/internal/CONVENTIONS-OVERVIEW.md': o.overview ?? CLEAN_POINTERIZED,
    'skills/tvu-design-mockup/SKILL.md': o.skillMockup ?? CLEAN_POINTERIZED,
    'skills/tvu-design-code/SKILL.md': CLEAN_POINTERIZED,
    'skills/role-ux/SKILL.md': CLEAN_POINTERIZED,
  }
  for (const rel of o.omit ?? []) delete files[rel]
  return createGateFixture({ gate: GATE, prefix: 'rule-inventory-fx', files })
}

describe('A 绿档（非空过凭据：钉 fixture 自己的读数，⛔ 不只看 exit 0）', () => {
  it('A1 干净 fixture ⇒ exit 0，自印的四份分母 + 两张扫描面都是 fixture 自己的，不是真仓库的', () => {
    const root = fixture()
    const r = runGate(root, GATE)
    // 与 expectGateGreen 的 status 断言重复，**刻意保留**：绿档被意外打红时
    // 光看「expected 1 to be 0」无从下手，把闸原始输出带进失败消息。
    expect(r.status, `闸的 stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    expectGateGreen(r, {
      contains: [
        'audit:rule-inventory — 覆盖面',
        'docs/internal/design-process.md：活源 3 个 H2 · inventory 3 项',
        'docs/internal/domain-tvu.md：活源 4 个 H2 · inventory 4 项',
        'docs/internal/mockup-conventions.md：活源 5 个 rule heading · inventory 5 项',
        'docs/internal/code-conventions.md：活源 6 个 rule heading · inventory 6 项',
        // 6/12 = 四份目标 + AGENTS + session-handoff；其余 6 份不在 fixture 里
        '6/12 份耐久规则文档',
        '1/6 份（阈值',
        // ⚠️ S7 的 4/4 **不是 fixture 独有读数**（注册面是硬编码常量，真仓库同样印 4/4）——
        // 它钉的是「这四份在 fixture 里齐了、且都干净」，区分真/假仓库靠上面四个分母。
        '4/4 份已指针化文档',
        '0 条（命中 0）',
      ],
    })
    // ⛔ 反向：真仓库的读数绝不该出现在 fixture 的输出里（闸退回真仓库读 ⇒ 本组必炸）
    expect(r.stdout).not.toContain('活源 25 个 H2')
    expect(r.stdout).not.toContain('活源 67 个 rule heading')
    expect(r.stdout).not.toContain('12/12 份耐久规则文档')
    expect(r.stdout).not.toContain('6/6 份（阈值')
  })

  it('A2 mode 分派真的生效：`rule-ids` 那两份不是按 H2 数的（两个反方向的钉）', () => {
    // ① mockup fixture 有 7 个 H2（2 个非规则 + 5 条规则）⇒ 走 level 模式会印 7
    // ② code   fixture 只有 3 个 H2（另 3 条规则挂 H3）⇒ 走 level 模式会印 3
    // 任一方向的分派退化都会让分母对不上 inventory ⇒ 闸当场变红，本条同时抓两向。
    const r = runGate(fixture(), GATE)
    expect(r.status).toBe(0)
    expect(r.stdout).toContain('docs/internal/mockup-conventions.md：活源 5 个 rule heading')
    expect(r.stdout).toContain('docs/internal/code-conventions.md：活源 6 个 rule heading')
    expect(r.stdout).not.toContain('活源 7 个')
    expect(r.stdout).not.toContain('活源 3 个 rule heading')
  })
})

describe('B 接线（这一层只 import 判据函数结构上看不见）', () => {
  it('B1 🔴 CLI 入口真的调用了 main —— 守卫恒假时会是「exit 0 且零输出」', () => {
    // 本闸的入口守卫是**原始** `resolve(argv[1]) === resolve(fileURLToPath(import.meta.url))`
    // （`:419`），`path.resolve` 只规范化、**不解 symlink** ⇒ fixture root 不 realpath 时
    // 它恒假、`main()` 整个不执行、exit 0、stdout 零字节。共享 harness 已在 root 上
    // `realpathSync`（2026-08-27），本条是那行的回归钉。
    // ⛔ 判据取**终态事实**：闸自己最后那句话必须是输出的最后一行 —— 只断言 exit 0
    //    的话，「整个不执行」与「跑完全绿」逐字相同。
    const r = runGate(fixture(), GATE)
    expect(r.status).toBe(0)
    expect(r.stdout.trim()).not.toBe('')
    const lines = r.stdout.trim().split('\n')
    expect(lines[lines.length - 1]).toContain(
      '✓ audit:rule-inventory PASS：规则清单与起手链路各只有一处真源。',
    )
  })

  it('B2 阻断出口真的接上了：有违例时 exit 1、点名判据码，且末行是闸自己的收尾句', () => {
    // 摘掉 `process.exit(1)` 那一支 ⇒ 明细照印而 status 变 0，前半条抓的就是那个差。
    // 后半条抓的是另一种假红：`console.error` 打完才崩 ⇒ 退出码碰巧也非零、点名也在，
    // 只有「末行是不是闸自己的收尾句」分得开（崩溃时末行是 `Node.js vXX` 栈尾）。
    const root = fixture({
      designProcess: designProcess({ ids: [...DP_IDS, 'FX-DP-FOUR'], listed: DP_IDS }),
    })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: ['[S3] docs/internal/design-process.md：inventory 缺 1 项 —— FX-DP-FOUR'],
    })
    const errLines = r.stderr.trim().split('\n')
    expect(errLines[errLines.length - 1]).toContain('维护：scripts/audit-rule-inventory.mjs')
  })

  it('B3 红档也照印覆盖面 —— 自印在判据之前，闸红的时候仍告诉你它扫了什么', () => {
    const root = fixture({
      designProcess: designProcess({ ids: [...DP_IDS, 'FX-DP-FOUR'], listed: DP_IDS }),
    })
    const r = runGate(root, GATE)
    expect(r.status).toBe(1)
    // 判据明细在 stderr，覆盖面在 stdout —— 两条流各自都要非空
    expect(r.stdout).toContain('audit:rule-inventory — 覆盖面')
    expect(r.stdout).toContain('docs/internal/design-process.md：活源 4 个 H2 · inventory 3 项')
    expect(r.stdout).toContain('6/12 份耐久规则文档')
    expect(r.stderr).toContain('[S3]')
  })
})

describe('C main() 的真实输入构建（readOrNull / 两张扫描面注册表 / 自枚举豁免）', () => {
  it('C1 目标文件读不到 ⇒ 走 S1 fail closed，⛔ 不是静默 skip', () => {
    const root = fixture({ omit: ['docs/internal/domain-tvu.md'] })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: ['[S1] docs/internal/domain-tvu.md：活源解析出 0 个 H2 标题 —— 闸已变盲，fail closed'],
    })
    // 自印如实反映「读不到」
    expect(r.stdout).toContain('docs/internal/domain-tvu.md：活源 0 个 H2 · inventory 缺标记块')
    // 正向锚点：另外三份照常有 fixture 自己的读数 ⇒ 不是「整个 fixture 都坏了」
    expect(r.stdout).toContain('docs/internal/design-process.md：活源 3 个 H2 · inventory 3 项')
    expect(r.stdout).toContain('docs/internal/code-conventions.md：活源 6 个 rule heading · inventory 6 项')
  })

  it('C2 缺 rule-inventory 标记块 ⇒ S2，且不叠报 S3（一条根因不刷屏）', () => {
    const root = fixture({ designProcess: designProcess({ marker: null }) })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S2] docs/internal/design-process.md：缺 <!-- rule-inventory:design-process --> 标记块',
      ],
    })
    expect(r.stdout).toContain('docs/internal/design-process.md：活源 3 个 H2 · inventory 缺标记块')
    // 活源 3 个 / 登记 0 个，若叠报 S3 会是 2 条违例
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S3]')
  })

  it('C3 marker 由 INVENTORIES.id 决定、不是由文件名猜 —— 写错 marker 仍是 S2', () => {
    // 块在，但 marker 写成了另一份的 id ⇒ 两份 inventory 不会互相顶替。
    const root = fixture({ designProcess: designProcess({ marker: 'domain-tvu' }) })
    expectGateRed(runGate(root, GATE), {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S2] docs/internal/design-process.md：缺 <!-- rule-inventory:design-process --> 标记块',
      ],
    })
  })

  it('C4 缺项与 phantom 各自点名 —— 活源与清单是从**同一份文件**的两处取的', () => {
    const root = fixture({
      designProcess: designProcess({ listed: ['FX-DP-ONE', 'FX-DP-TWO', 'FX-DP-PHANTOM'] }),
    })
    expectGateRed(runGate(root, GATE), {
      marker: '❌ audit:rule-inventory FAIL：2 条违例',
      checks: [
        '[S3] docs/internal/design-process.md：inventory 缺 1 项 —— FX-DP-THREE',
        '[S3] docs/internal/design-process.md：inventory 有 1 项 phantom（活源无此标题）—— FX-DP-PHANTOM',
      ],
    })
  })

  it('C5 MIRROR_SCAN_DOCS 逐份都被扫 —— 故障只造在第 6 份也必须被点名', () => {
    // 退化成「只扫第一份（AGENTS.md）」时本条红，而 A 的绿档与 C7 都照样绿 ⇒ 只有它抓得到。
    const root = fixture({ sessionHandoff: `${CLEAN_HANDOFF}\n${MIRROR_LINE}\n` })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S4] docs/session-handoff.md:',
        '又枚举了 design-process.md 的清单（3 项：FX-DP-ONE / FX-DP-TWO / FX-DP-THREE）',
      ],
    })
    // 正向锚点：AGENTS.md 确实在扫描面里（6/12 的分子含它）却干净 ⇒ 上面那条不是「见谁点谁」
    expect(r.stdout).toContain('6/12 份耐久规则文档')
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S4] AGENTS.md')
  })

  it('C6 目标文件自己头部列清单**不**算「别处又枚举一遍」（must-not-hit）', () => {
    // `doc === inv.target continue` 那句。它没了的话，每份目标文件的 rule-inventory 块
    // 自己就会把自己判成 S4 违例 —— 闸永远红、且红得毫无道理。
    const root = fixture({ designProcess: designProcess({ extra: [MIRROR_LINE] }) })
    const r = runGate(root, GATE)
    expect(r.status, `闸的 stderr:\n${r.stderr}`).toBe(0)
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S4]')
    // 非空过凭据：这一趟确实跑完了整条判据链
    expect(r.stdout).toContain('docs/internal/design-process.md：活源 3 个 H2 · inventory 3 项')
  })

  it('C7 同一行搬到别处就红 —— C6 的正向锚点（证明那行本身够格触发 S4）', () => {
    const root = fixture({ agents: `${CLEAN_AGENTS}${MIRROR_LINE}\n` })
    expectGateRed(runGate(root, GATE), {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S4] AGENTS.md:',
        '又枚举了 design-process.md 的清单（3 项：FX-DP-ONE / FX-DP-TWO / FX-DP-THREE）',
      ],
    })
  })

  it('C8 CHAIN_SCAN_DOCS 被消费，且两个起手链路真源不在这张面里', () => {
    // 完全相同的一段链路，放 session-handoff（在 S6 面内）红、放 AGENTS（是真源、不在面内）不红。
    const root = fixture({
      sessionHandoff: `${CLEAN_HANDOFF}\n${CHAIN_BLOCK}`,
      agents: `${CLEAN_AGENTS}\n${CHAIN_BLOCK}`,
    })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      // 「1 条」本身就是最强锚点：AGENTS 若也在 S6 面里，这里会是 2 条
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [`[S6] docs/session-handoff.md:`, `自建起手必读链路（${CHAIN_PATHS}）`],
    })
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S6] AGENTS.md')
    // 正向锚点：AGENTS 这一趟确实被读了（它算进 S4 面的分子）
    expect(r.stdout).toContain('6/12 份耐久规则文档')
  })

  it('C9 S7 受管文件读不到 ⇒ fail closed，且扫描面分子跟着掉（2026-09-03 S7 落地补）', () => {
    // ⛔ 这一条只有整脚本面看得见：纯函数那份传的是构造好的 `{missing:true}`，
    //    `readOrNull → missing` 那段**接线**它结构上摸不到。
    const root = fixture({ omit: ['skills/role-ux/SKILL.md'] })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: ['[S7] skills/role-ux/SKILL.md：受管指针化文档读不到', 'fail closed'],
    })
    // 分子真的从存在性探测来：4/4 → 3/4。若它是常量，这里仍会印 4/4。
    expect(r.stdout).toContain('3/4 份已指针化文档')
    expect(r.stdout).not.toContain('4/4 份已指针化文档')
  })

  it('C10 S7 inline：单行 ≥3 个裸 id ⇒ 红并点名 file:line（S4 对这行是瞎的）', () => {
    const root = fixture({ overview: `${CLEAN_POINTERIZED}\n${S7_INLINE_LINE}\n` })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      // 「1 条」是锚点：这一行不提任何目标文件 basename ⇒ S4 不该也开一枪
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S7] docs/internal/CONVENTIONS-OVERVIEW.md:',
        '单行枚举 3 个规则 ID（M-FXPA / M-FXPB / M-FXPC）',
      ],
    })
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S4]')
  })

  it('C11 S7 table：连续 3 行首列 ID 打头 ⇒ 红；同一批 id 每行只 1 个，阈值型 inline 判据够不着', () => {
    const root = fixture({ skillMockup: `${CLEAN_POINTERIZED}\n${S7_TABLE_BLOCK}\n` })
    const r = runGate(root, GATE)
    expectGateRed(r, {
      marker: '❌ audit:rule-inventory FAIL：1 条违例',
      checks: [
        '[S7] skills/tvu-design-mockup/SKILL.md:',
        '规则 ID 枚举表回潮（连续 3 行首列 ID：M-FXPA / M-FXPB / M-FXPC）',
      ],
    })
    // must-not-hit：报的是 table 不是 inline —— 证明两条形态没有互相顶替
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('单行枚举')
  })
})

describe('D 判据合流（三类都进同一个 failures 数组、同一个出口）', () => {
  it('D1 S3 + S4 + S6 同时开火 ⇒ 违例数是三者之和，且三个码都点名', () => {
    // 少接任一类（或先 return 掉一类）时，总数会是 1 或 2 而不是 3。
    const root = fixture({
      designProcess: designProcess({ ids: [...DP_IDS, 'FX-DP-FOUR'], listed: DP_IDS }),
      agents: `${CLEAN_AGENTS}${MIRROR_LINE}\n`,
      sessionHandoff: `${CLEAN_HANDOFF}\n${CHAIN_BLOCK}`,
    })
    expectGateRed(runGate(root, GATE), {
      marker: '❌ audit:rule-inventory FAIL：3 条违例',
      checks: [
        '[S3] docs/internal/design-process.md：inventory 缺 1 项 —— FX-DP-FOUR',
        '[S4] AGENTS.md:',
        '[S6] docs/session-handoff.md:',
      ],
    })
  })
})

describe('E fixture 有效性自证（⛔ 防「fixture 建错了但测试照样绿」）', () => {
  it('E1 扫描面的分子是**真实存在性**探测出来的，不是常量', () => {
    // 拿掉 AGENTS.md ⇒ 6/12 变 5/12。若 mirrorFileCount 写死成 MIRROR_SCAN_DOCS.length，
    // 这个数不会动，而闸看起来照样「扫了 12 份」。
    const root = fixture({
      omit: ['AGENTS.md'],
      sessionHandoff: `${CLEAN_HANDOFF}\n${MIRROR_LINE}\n`,
    })
    const r = runGate(root, GATE)
    expect(r.stdout).toContain('5/12 份耐久规则文档')
    expect(r.stdout).not.toContain('6/12 份耐久规则文档')
    // 正向锚点：少一份不代表判据也停了 —— 留下的那份里的违例照样被抓
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('[S4] docs/session-handoff.md:')
  })

  it('E2 S4 阈值真的从 MIN_IDS_FOR_MIRROR 走：2 个 id 是引用不是清单（must-not-hit）', () => {
    // 正向锚点是 C7：同一份文件、同一个位置，3 个 id 就红。
    const root = fixture({ agents: `${CLEAN_AGENTS}${MIRROR_LINE_2IDS}\n` })
    const r = runGate(root, GATE)
    expect(r.status, `闸的 stderr:\n${r.stderr}`).toBe(0)
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('[S4]')
    // 非空过凭据：这一趟 AGENTS.md 确实进了扫描面
    expect(r.stdout).toContain('6/12 份耐久规则文档')
  })
})
