import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  writeFixtureFile,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'
// ⚠️ 仅用于构造 fixture 的输入面 —— 理由见下方 `baseFiles` 的注释。
import { SCAN_FILES, BASELINE_UNCLASSIFIED } from '../scripts/audit-acceptance-gate-coverage.mjs'
import { readFileSync } from 'node:fs'

/**
 * `audit-acceptance-gate-coverage.mjs` —— **整脚本**回归面（[[INFRA-F138]]）。
 *
 * ⛔ 刻意不 import 该闸的任何符号：只 import 判据函数的测试**结构上看不见接线**
 * （摘掉 `main()` 照样全绿）。这里 spawn 整个脚本，被测对象一行不改。
 *
 * 本闸的输入面全是文件系统（三份规则真源 + `scripts/` 下的闸自陈 + 豁免表），
 * **无 net / cred** ⇒ 能零改动喂进 fixture root。这也是它与
 * `audit-mockup-geometry-consistency.mjs` 的关键差别：后者打 Figma API，
 * 在拿到 API stub 之前做不了整脚本回归面（那条的缺口已在其头注释如实登记）。
 */

const GATE = 'scripts/audit-acceptance-gate-coverage.mjs'

/** 闸里写死的存量快照。fixture 要能越过它才测得到 S2。 */
// ⚠️ 从闸 import，⛔ 别写死 —— 这个数每次真的分类掉一批就要收一次（84 → 83 → 70 …），
// 写死就是第二份会漂的副本，而漂了以后这几条 S2 测试测的是一个不存在的边界。
const BASELINE = BASELINE_UNCLASSIFIED

/** 造一份含 N 个 Acceptance 段的规则真源。 */
function ruleDoc(sections: Array<{ heading: string; body: string }>): string {
  return sections.map(s => `## ${s.heading}\n\n${s.body}\n`).join('\n')
}

/**
 * 全扫描面的最小 fixture：只有 domain-tvu 放真内容，其余各份给空壳（段数可控）。
 *
 * ⚠️ **2026-08-27 从写死三份改为按 `SCAN_FILES` 生成**（扫描面扩到 7 份那次）。写死的版本
 * 当场让 16/32 条转红 —— 成因不是判据坏了，正是 **S1 fail-closed 在正确工作**
 * （「扫描面文件缺失 ⇒ 红」），只是 fixture 没跟上。⇒ 改 fixture、⛔ 别动实现
 * （memory `plan-fixture-is-not-the-criterion-source`）。
 *
 * ⚠️ 这是本文件**唯一**一处 import 被测闸的符号，与顶部那条「⛔ 刻意不 import」不矛盾：
 * 它只用来**构造输入面**，被测对象仍是 spawn 出来的整个脚本（接线照样在覆盖面里）。
 * 写死第三份清单才是真风险 —— 扫描面一扩就得改三处（SCAN_FILES / .husky/pre-commit / 这里），
 * 而只有前两处漏改会被发现。S1 本身另有独立一条测试钉着，不靠本函数。
 */
function baseFiles(domainTvu: string): Record<string, string> {
  const out: Record<string, string> = {}
  for (const f of SCAN_FILES) {
    out[f] = f === 'docs/internal/domain-tvu.md'
      ? domainTvu
      : `# ${f}\n\n（本 fixture 刻意不放 Acceptance 段）\n`
  }
  return out
}

/** 一个假闸，头注释里带 covers-acceptance 自陈。 */
function fakeGate(decl: string): string {
  return `// fake-gate.mjs\n//\n// 挂载层：fixture 用，不是真闸。\n//\n${decl}\n//\nexport const x = 1\n`
}

const M17 = {
  heading: 'M17 — 新页面一律照该 surface 既有 canonical 范式',
  // 逐字复刻真源的形态：4 条 checklist，其中第 2 条用 `·` 分出 4 个核查项
  body: [
    '**Acceptance（通则层，不含具体取值）**：',
    '',
    '- [ ] 新页面开建前写明「宿主范式来源」',
    '- [ ] 几何一致性至少核到这几类：**同类控件是否同宽** · **同一控件在各状态帧之间是否同宽** · **动作按钮右缘是否对齐控件右缘** · 行高与分隔线间距',
    '- [ ] 上述核查**贴实测数字**，目测不算通过',
    '- [ ] 按钮宽度按文字 hug + 统一 padding',
  ].join('\n'),
}

// 裸 `- ` 形态（真源 §M10 就是这么写的）——只认 `- [ ]` 会整类漏掉
const M10 = {
  heading: 'M10 — List row Inactive 状态至少分两子型',
  body: ['**Acceptance:**', '', '- Component 设计：variant property `state` 至少包含三值', '- Mockup 设计：副标题措辞必须区分'].join('\n'),
}

afterEach(() => cleanupGateFixtures())

describe('绿档 —— 读数必须是 fixture 自己的（非空过凭据）', () => {
  it('无自陈无豁免时全部落 unclassified，且数目对得上', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-green',
      files: baseFiles(ruleDoc([M17, M10])),
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, {
      contains: [
        'Acceptance 段 2 个',
        '闸自陈 0 条',
        'gated 0 · not-machine-checkable 0 · unclassified 2',
      ],
    })
    // ⛔ 绿不等于「都有闸了」——这句每次必印
    expect(run.stdout).toContain('unclassified 不是「没问题」')
  })

  it('闸自陈后该段转 gated（fixture 自己的数）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-gated',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['闸自陈 1 条', 'gated 1 · not-machine-checkable 0 · unclassified 1'] })
  })

  it('豁免表让该段转 not-machine-checkable', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-allow',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json':
          JSON.stringify([{ id: 'docs/internal/domain-tvu.md#M10', reason: '纯语义判断，无可机械化的判据' }]),
      },
    })
    const run = runGate(root, GATE)
    expectGateGreen(run, { contains: ['豁免 1 条', 'gated 0 · not-machine-checkable 1 · unclassified 1'] })
  })
})

describe('分母口径 —— 两种 checklist 形态 + `·` 核查项计数', () => {
  it('裸 `- ` 形态也进分母（只认 `- [ ]` 会漏掉整类，真源 §M10 就是裸的）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-bare',
      files: {
        ...baseFiles(ruleDoc([M10])),
        // 自陈 [2]：M10 的两条 checklist 都无 `·` ⇒ 每条 1 项 ⇒ **段内核查项总数 = 2**。
        // ⚠️ 2026-08-27 前这里写 [1]（旧 max 语义）。改成 Σ 正是本轮那个修复：
        // 「一项一行」形态下 max 恒为 1，新增一条核查项 max 不变 ⇒ 旧判据放行。
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M10 [2]'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['Acceptance 段 1 个', 'gated 1'] })
  })

  it('`·×3` 数成 4 个核查项（真源 §M17:214 的实际形态）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-dots',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['gated 1'] })
  })

  /**
   * 🔴 **这条钉的是 2026-08-27 的段边界收窄，⛔ 别删。**
   *
   * 旧边界只认「下一个 heading / 下一个 Acceptance 标题」⇒ Acceptance 之后、
   * 下个 heading 之前的**所有**顶层列表项都被算成核查项。真源实测：
   * `domain-tvu.md#M17` 因此把 5 条「LCD surface 注记（做法层）」算进了它的 4 条
   * Acceptance（9 条 vs 真实 4 条）。这直接污染 S4 的 Σ。
   *
   * 本 fixture 逐字复刻那个形态：Acceptance 列表 → 顶层散文行 → 又一批顶层列表项。
   * 新边界在那个散文行处收住 ⇒ 只数 Acceptance 自己的 2 条（Σ=2）。
   * 旧边界会数成 4 条（Σ=4）⇒ 自陈 [2] 会被判 S4 红。
   */
  it('Acceptance 之后的顶层散文行终止该段 —— 后面的做法层列表项不算核查项', () => {
    const withTrailingList = {
      heading: 'M17 — 带做法层注记的形态',
      body: [
        '**Acceptance（通则层）**：',
        '',
        '- [ ] 第一条核查项',
        '- [ ] 第二条核查项',
        '',
        '**LCD surface 注记（做法层；取值见项目 repo）**',
        '',
        '- **双背景色**：页 / 导航 / 底栏一色 —— 这不是核查项',
        '- **展开标记**：下向三角 vector —— 这也不是核查项',
      ].join('\n'),
    }
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-boundary',
      files: {
        ...baseFiles(ruleDoc([withTrailingList])),
        // Σ 只数 Acceptance 自己那 2 条 ⇒ [2]。旧边界会数成 4 ⇒ 这里会红。
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [2]'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['Acceptance 段 1 个', 'gated 1'] })
  })

  it('散文形态的 Acceptance 段（0 条 checklist）照样进分母', () => {
    const prose = { heading: 'M11 — Trend visualization', body: '**Acceptance**\n\n任何 trend rule 必须同时描述三层信号映射表。' }
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-prose',
      files: baseFiles(ruleDoc([prose])),
    })
    expectGateGreen(runGate(root, GATE), { contains: ['Acceptance 段 1 个', 'unclassified 1'] })
  })
})

describe('S4 —— 自陈子项数对位（CANONICAL-F104 那个「4 项实现 3 项」的病）', () => {
  it('少报一个核查项 ⇒ 红且点名 S4', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s4',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [3]'),
      },
    })
    expectGateRed(runGate(root, GATE), {
      marker: 'audit:acceptance-gate-coverage FAIL',
      checks: ['[S4]', '逐字有 **7** 个核查项', '自陈覆盖 **3** 个'],
    })
  })

  it('规则里新增第 5 个核查项（`·` 形态：往现有那条塞）而自陈没跟上 ⇒ 红', () => {
    const grown = {
      ...M17,
      body: M17.body.replace('· 行高与分隔线间距', '· 行高与分隔线间距 · **新加的第五项**'),
    }
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s4-grow',
      files: {
        ...baseFiles(ruleDoc([grown])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S4]', '逐字有 **8** 个核查项', '自陈覆盖 **7** 个'] })
  })

  /**
   * 🔴 **这条钉的是 2026-08-27 那个修复本身，⛔ 别删。**
   *
   * 上面那条走 `·` 形态（往现有 checklist 条里塞一项）。本条走**另一种书写形态**：
   * 给 Acceptance **新增一整条** `- [ ]`。两者语义完全相同（规则多了一个核查项），
   * 而**旧判据 `actual = Math.max(各条项数)` 对本条结构性失效** —— 新增的那条只有 1 项、
   * 不改 max ⇒ 闸放行。当日在真仓库 `domain-tvu.md#M17` 上注入实证过：
   * 新增一行 ⇒ `REAL_EXIT=0` 且逐字印「自陈子项数对得上」。
   *
   * 而本仓 118 段里 **104 段** 是「一项一行」（每条 1 个核查项、无 `·`），
   * 即旧判据对绝大多数段只能核到「有没有人往某条塞 `·`」。⇒ 改 Σ 后本条才红。
   */
  it('规则里新增第 5 个核查项（新增一条 `- [ ]` 形态）而自陈没跟上 ⇒ 也必须红', () => {
    const grownByRow = {
      ...M17,
      // ⛔ 这行探针文本里**不许出现分隔符本身** —— 第一次写成「（一项一行，不含 `·`）」，
      //    那个字符正是 `countCheckItems` 的判据 ⇒ 该条被数成 2 项、Σ=9 而不是 8，
      //    测试红的原因变成我自己造的污染。逐字命中 memory
      //    `fault-probe-name-must-be-absent-from-repo`「探针值撞被测文件自己的判据字符」。
      body: `${M17.body}\n- [ ] 新增的第五条核查项（一项一行形态）`,
    }
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s4-grow-row',
      files: {
        ...baseFiles(ruleDoc([grownByRow])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    // Σ 从 7（1+4+1+1）涨到 8；旧的 max 仍是 4 ⇒ 旧判据下这里会**绿**。
    expectGateRed(runGate(root, GATE), { checks: ['[S4]', '逐字有 **8** 个核查项', '自陈覆盖 **7** 个'] })
  })

  it('不写 [N] 就不核（可选，不把没准备好的闸逼进假声明）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s4-opt',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['gated 1'] })
  })
})

describe('S2 —— unclassified 棘轮（只许缩）', () => {
  it(`超过 BASELINE ${BASELINE} ⇒ 红且点名 S2`, () => {
    const many = Array.from({ length: BASELINE + 1 }, (_, i) => ({
      heading: `R${i} — probe rule`,
      body: '**Acceptance:**\n\n- 一条验收项',
    }))
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s2',
      files: baseFiles(ruleDoc(many)),
    })
    expectGateRed(runGate(root, GATE), {
      checks: [`[S2] unclassified ${BASELINE + 1} > BASELINE ${BASELINE}`, '⛔ 别改 BASELINE 放行'],
    })
  })

  it(`恰好等于 BASELINE ${BASELINE} ⇒ 绿（边界，不是 off-by-one）`, () => {
    const many = Array.from({ length: BASELINE }, (_, i) => ({
      heading: `R${i} — probe rule`,
      body: '**Acceptance:**\n\n- 一条验收项',
    }))
    const root = createGateFixture({ gate: GATE, prefix: 'acc-s2-eq', files: baseFiles(ruleDoc(many)) })
    expectGateGreen(runGate(root, GATE), { contains: [`unclassified ${BASELINE}（BASELINE ${BASELINE}`] })
  })

  it('上闸后给新段配自陈 ⇒ 重新变绿（棘轮可满足，不是死闸）', () => {
    const many = Array.from({ length: BASELINE + 1 }, (_, i) => ({
      heading: `R${i} — probe rule`,
      body: '**Acceptance:**\n\n- 一条验收项',
    }))
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s2-fix',
      files: {
        ...baseFiles(ruleDoc(many)),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#R0'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: [`gated 1`, `unclassified ${BASELINE}`] })
  })
})

/**
 * 文件级豁免（2026-08-27 加，owner 当日拍定）。判据 = 豁免 `id` 里有没有 `#`。
 *
 * 立项理由：实测有整份文件的 Acceptance 结构上都落在产物之外
 * （`figma-technical-reference.md` 那 17 段全是 `use_figma` 脚本编写纪律，脚本是对话内一次性 JS）。
 * 逐段写 17 行同一个理由 = 把本闸当初拒绝的「600+ 噪声墙」搬到豁免表层。
 */
describe('豁免粒度 —— 段级 + 文件级', () => {
  it('文件级豁免（id 无 `#`）一次盖住该文件全部段', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-allow-file',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json': JSON.stringify([
          { id: 'docs/internal/domain-tvu.md', reason: '整份文件的 Acceptance 都是对话内一次性脚本的编写纪律，仓库产物侧无对象可扫' },
        ]),
      },
    })
    // 两段都在 domain-tvu.md 里 ⇒ 一条豁免盖住 2 段
    expectGateGreen(runGate(root, GATE), { contains: ['豁免 1 条', 'gated 0 · not-machine-checkable 2 · unclassified 0'] })
  })

  it('文件级豁免下，某段若有闸自陈 ⇒ 按 gated 记（自陈优先，⛔ 不判矛盾）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-allow-file-gated',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json': JSON.stringify([
          { id: 'docs/internal/domain-tvu.md', reason: '整份文件默认不可机检' },
        ]),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['gated 1 · not-machine-checkable 1 · unclassified 0'] })
  })

  it('文件级豁免指向的文件没有任何 Acceptance 段 ⇒ 红并要求删行（shrink-only 对两种粒度同权）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-allow-file-stale',
      files: {
        ...baseFiles(ruleDoc([M17])),
        // mockup-conventions.md 在 baseFiles 里刻意没有 Acceptance 段 ⇒ 这条文件级豁免空转
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json': JSON.stringify([
          { id: 'docs/internal/mockup-conventions.md', reason: '空转的文件级豁免' },
        ]),
      },
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[S3]', 'docs/internal/mockup-conventions.md', '文件级', '删掉这行'],
    })
  })

  it('--list 标出豁免是文件级还是段级（读者要能分辨盖了多大面）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-allow-scope-list',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json': JSON.stringify([
          { id: 'docs/internal/domain-tvu.md', reason: '文件级理由' },
          { id: 'docs/internal/domain-tvu.md#M10', reason: '段级理由' },
        ]),
      },
    })
    const run = runGate(root, GATE, ['--list'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain('[文件级] 文件级理由')
    expect(run.stdout).toContain('[段级] 段级理由')
  })
})

describe('S3 —— 豁免表 shrink-only + 自陈不许悬挂', () => {
  it('豁免条目不再命中活源 ⇒ 红并要求删行', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s3-stale',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json':
          JSON.stringify([{ id: 'docs/internal/domain-tvu.md#M999', reason: '已不存在' }]),
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S3]', '不再命中活源', '删掉这行'] })
  })

  it('同一段既有闸自陈又挂豁免 ⇒ 红（矛盾）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s3-conflict',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json':
          JSON.stringify([{ id: 'docs/internal/domain-tvu.md#M17', reason: '矛盾条目' }]),
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S3]', '既有闸自陈覆盖、又挂在豁免表里'] })
  })

  it('自陈指向不存在的段 ⇒ 红（闸写错了 target）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s3-dangle',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M999 [4]'),
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S3]', '活源里没有这个 Acceptance 段'] })
  })
})

describe('S1 —— fail closed', () => {
  it('扫描面文件缺失 ⇒ 红（⛔ 不静默跳过）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s1-missing',
      files: {
        'docs/internal/domain-tvu.md': ruleDoc([M17]),
        // 刻意不写另两份
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S1]', '扫描面文件缺失', '⛔ 不许静默跳过'] })
  })

  it('豁免表畸形（缺 reason）⇒ 红', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s1-malformed',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json':
          JSON.stringify([{ id: 'docs/internal/domain-tvu.md#M17' }]),
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S1]', '非空 reason'] })
  })

  it('豁免表 JSON 坏 ⇒ 红', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s1-badjson',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'figma-data/audit-allowlist/acceptance-not-machine-checkable.json': '{ 这不是 JSON',
      },
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S1]', 'JSON 解析失败'] })
  })

  it('一个 Acceptance 段都没扫到 ⇒ 红（⛔ 不是「没有 Acceptance」）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-s1-empty',
      files: baseFiles('# domain\n\n（没有任何 Acceptance 段）\n'),
    })
    expectGateRed(runGate(root, GATE), { checks: ['[S1]', '枚举器坏了'] })
  })
})

describe('CLI 形态', () => {
  it('--list 是 report-only（exit 0），且逐段列出三态', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-list',
      files: {
        ...baseFiles(ruleDoc([M17, M10])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    const run = runGate(root, GATE, ['--list'])
    expectGateGreen(run, { contains: ['docs/internal/domain-tvu.md#M17', '⬜ unclassified', 'report-only'] })
    expect(run.stdout).toContain('fake-gate.mjs')
  })

  it('--list 在本该红的状态下也 exit 0（它不是闸）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-list-red',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/fake-gate.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [3]'),
      },
    })
    // 不带 --list 是红的（S4）
    expectGateRed(runGate(root, GATE), { checks: ['[S4]'] })
    // 带 --list 只报告
    expect(runGate(root, GATE, ['--list']).status).toBe(0)
  })

  it('未知参数 ⇒ exit 2（fail closed，⛔ 不静默当默认跑）', () => {
    const root = createGateFixture({ gate: GATE, prefix: 'acc-arg', files: baseFiles(ruleDoc([M17])) })
    const run = runGate(root, GATE, ['--totally-unknown'])
    expect(run.status).toBe(2)
    expect(`${run.stderr}${run.stdout}`).toContain('未知参数')
  })

  it('--json 输出可解析且带三态计数', () => {
    const root = createGateFixture({ gate: GATE, prefix: 'acc-json', files: baseFiles(ruleDoc([M17, M10])) })
    const run = runGate(root, GATE, ['--json'])
    expect(run.status).toBe(0)
    const parsed = JSON.parse(run.stdout)
    expect(parsed.sections).toBe(2)
    expect(parsed.counts.unclassified).toBe(2)
    expect(parsed.rows).toHaveLength(2)
  })
})

describe('自陈解析边界', () => {
  it('埋在文件中段（60 行外）的自陈不算数 —— 声明必须在读者一眼能看到的地方', () => {
    const buried = `// fake\n${'//\n'.repeat(70)}// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]\nexport const x = 1\n`
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-buried',
      files: { ...baseFiles(ruleDoc([M17])), 'scripts/fake-gate.mjs': buried },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['闸自陈 0 条', 'unclassified 1'] })
  })

  it('只扫 .mjs（非脚本文件里的同形文本不算自陈）', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-ext',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/notes.txt': '// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]',
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['闸自陈 0 条'] })
  })

  it('同一段被两条闸自陈 ⇒ 都记，仍是 gated', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-multi',
      files: {
        ...baseFiles(ruleDoc([M17])),
        'scripts/gate-a.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
        'scripts/gate-b.mjs': fakeGate('// covers-acceptance: docs/internal/domain-tvu.md#M17 [7]'),
      },
    })
    expectGateGreen(runGate(root, GATE), { contains: ['闸自陈 2 条', 'gated 1'] })
  })
})

/**
 * ⚠️ 本组**不用 fixture，读真仓库的活源** —— 它守的不是判据，是**触发面**。
 *
 * `SCAN_FILES`（判据的分母）与 `.husky/pre-commit` 里那条 `grep -qE` 正则（L4 什么时候开火）
 * 是两处必须同时改的副本。⛔ 漏改 husky 那处**不会红** —— 闸只是不跑，本地拿不到信号，
 * 新写的 Acceptance 段要拖到 L5（gate-chain）才被拦。这正是本仓
 * `verification-criterion-must-be-terminal-state` 记的第四种形态（「绿是真的，
 * 而关心的对象根本不在这条闸的扫描面里」）在**挂载层**的镜像。⇒ 用一条测试把它变成会红的。
 */
describe('L4 触发面 ↔ SCAN_FILES 对位（活源，非 fixture）', () => {
  const hookText = readFileSync('.husky/pre-commit', 'utf8')

  /** 取 acceptance 块那条 `grep -qE '<re>'` 的正则本体。fail-closed：找不到就红。 */
  function acceptanceTriggerRegex(): string {
    const lines = hookText.split('\n')
    const runIdx = lines.findIndex(l => l.includes('pnpm run audit:acceptance-gate-coverage'))
    expect(runIdx, '.husky/pre-commit 里找不到 audit:acceptance-gate-coverage 的调用行 —— 闸被摘了挂载？').toBeGreaterThan(-1)
    for (let i = runIdx; i >= 0; i--) {
      const m = lines[i].match(/grep -qE '(.+)'/)
      if (m) return m[1]
    }
    throw new Error('acceptance 块里找不到 grep -qE 触发条件 —— 触发面被改成别的形态了，本测试要跟着改')
  }

  it('SCAN_FILES 每一份都在 L4 触发正则里（漏一份 = 改它时闸静默不跑）', () => {
    const re = new RegExp(acceptanceTriggerRegex())
    for (const f of SCAN_FILES) {
      expect(re.test(f), `扫描面 ${f} 不在 .husky/pre-commit 的触发正则里 —— 改它时 L4 不会跑这条闸`).toBe(true)
    }
  })

  it('阴性对照：该正则不是 catch-all（不相干的路径不该命中）', () => {
    // 没有这条，上面那条可以被一个 `.` 正则空过 —— 全绿而触发面等于失控。
    const re = new RegExp(acceptanceTriggerRegex())
    for (const notScanned of ['docs/STATUS.md', 'src/components/Button.vue', 'README.md']) {
      expect(re.test(notScanned), `${notScanned} 不在扫描面里，却命中了触发正则 —— 正则太宽`).toBe(false)
    }
  })
})

describe('锚点寻址 —— 更靠前处插一个 Acceptance 段，不许静默改指其后的自陈（[[INFRA-F142]] 候选解 ②）', () => {
  /**
   * 治的是**哑的**失效：`Acceptance~N` 是序号不是锚点，在同一份真源更靠前处插入任一
   * Acceptance 段，其后每一条自陈都被顶到别的段上，而三态计数一个不变。
   * ⚠️ 所以下面**不能只断言 `gated 1`** —— 顶位后它照样是 1。必须跑 `--list` 看那个 ✅
   * 落在哪一段上。这正是 S4 抓不到的那一档（漂移后撞上条数相同的段就恒绿）。
   */
  const rule = (id: string, item: string) =>
    [`## ${id} — 规则正文`, '', '#### 反例', '', '- 一条反例（不是核查项）', '', '#### Acceptance', '', `- ${item}`, ''].join('\n')

  const DECL = '// covers-acceptance: docs/internal/domain-tvu.md#R2 [1]'

  /** `--list` 里某个 id 那一行是不是 ✅（gated）。 */
  function stateOf(stdout: string, id: string): string {
    const line = stdout.split('\n').find(l => l.includes(`${id}:`))
    if (!line) throw new Error(`--list 里没有 ${id} —— 锚点形态变了，本测试要跟着改`)
    return line.includes('✅') ? 'gated' : line.includes('➖') ? 'nmc' : 'unclassified'
  }

  function listWith(prefix: string, doc: string): string {
    const root = createGateFixture({
      gate: GATE, prefix,
      files: { ...baseFiles(doc), 'scripts/fake-gate.mjs': fakeGate(DECL) },
    })
    const run = runGate(root, GATE, ['--list'])
    expect(run.status).toBe(0)
    return run.stdout
  }

  it('锚点是拥有这段的规则 id，不是 `Acceptance` 这个词', () => {
    const out = listWith('acc-anchor-base', rule('R1', 'R1 的核查项') + '\n' + rule('R2', 'R2 的核查项'))
    expect(out).toContain('docs/internal/domain-tvu.md#R2')
    // ⛔ 阴性对照：旧的序号形态一个都不许再出现，否则说明又塌回 `Acceptance` 这个 slug 了
    expect(out).not.toContain('docs/internal/domain-tvu.md#Acceptance')
  })

  it('在自陈目标之前插入一个新规则及其 Acceptance 段，自陈仍落在原来那一段上', () => {
    const before = listWith('acc-anchor-b', rule('R1', 'R1 的核查项') + '\n' + rule('R2', 'R2 的核查项'))
    expect(stateOf(before, 'docs/internal/domain-tvu.md#R2')).toBe('gated')

    const after = listWith(
      'acc-anchor-a',
      rule('R0', 'R0 的核查项') + '\n' + rule('R1', 'R1 的核查项') + '\n' + rule('R2', 'R2 的核查项'),
    )
    // 段数 +1，但那个 ✅ 必须还在 R2 上 —— 序号寻址时它会被顶到 R1 去
    expect(after).toContain('Acceptance 段 3 个')
    expect(stateOf(after, 'docs/internal/domain-tvu.md#R2')).toBe('gated')
    expect(stateOf(after, 'docs/internal/domain-tvu.md#R1')).toBe('unclassified')
    expect(stateOf(after, 'docs/internal/domain-tvu.md#R0')).toBe('unclassified')
  })

  it('规则标题改名仍然会红（S3）—— 锚点失效必须是响的，⛔ 不许静默', () => {
    const root = createGateFixture({
      gate: GATE, prefix: 'acc-anchor-rename',
      files: {
        ...baseFiles(rule('R1', 'R1 的核查项') + '\n' + rule('R2renamed', 'R2 的核查项')),
        'scripts/fake-gate.mjs': fakeGate(DECL),
      },
    })
    expectGateRed(runGate(root, GATE), { contains: ['[S3]'] })
  })
})
