// tests/audit-gate-mount-declaration.test.ts
// -----------------------------------------------------------------------------
// `audit:gate-mount-declaration`（L4 pre-commit + L5 gitea-pr-checks）的**整脚本**回归面。
// 这是一条**元闸** —— 它守的是「每个闸脚本的头注释必须自己说清挂在哪一层」（[[INFRA-F126]]）。
//
// 为什么是整脚本：该闸只导出 `classifyHead` 一个符号，而它真正的判据面还有三块在顶层
// 且**结构上 import 不到** —— 扫描面前缀过滤、`KNOWN_SILENT` 具名豁免的命中、
// 以及 shrink-only 的 **stale 判据**（豁免失效要报 FAIL）。量具
// `pnpm report:gate-regression-face` 把它记为「零判据覆盖 · P1/P2 双 PASS」。
// 本文件是那条判定的兑现（[[INFRA-F138]]）。⛔ 闸本体一行没改。
//
// ⚠️ **`KNOWN_SILENT` 不在本文件里抄第二份** —— 它硬编码在闸脚本内，测试从**闸源码解析**
// 它的活源（解析不到就抛 = fail-closed）。抄一份就是本仓明令禁止的会漂副本，而且这条闸
// 自己的头注释逐字写着「别让头注释里的清单在自己身上重演 stale」。
//
// ⛔⛔ **fixture 里的脚本名必须是假名，不许写任何真实闸的 `<name>.mjs`**（2026-08-26 实证）：
// `gate-regression-face` 的 E 类判据是「exec 行往下 3 行的窗口里出现该闸的 basename」，
// 而 fixture 对象字面量就紧挨着 `runGate(` —— 本文件初版在一条 D2 用例里写了真实的
// `audit-mockup-conformance`（当时带着 `.mjs` 后缀）当「父闸」示例，量具当场把**那条闸**从「仅判据逻辑」
// 提到了 `E-fx`（接线有覆盖），而它的接线一行测试都没有。
// ⇒ 这是**高报**，比 [[INFRA-F138]] entry 诚实边界 ④ 记的「M 类被闸名污染」更危险：
//    ④ 那种只是让零覆盖类组内部挪位，这种直接把零覆盖伪装成已覆盖。
// 判据脱敏很便宜：名字不带 `.mjs` 后缀就不匹配 basename，不带 `audit:` 冒号就不匹配 npm key。
// ⇒ **fixture 用 `*-fx-*` 假名，散文里提真实闸时去掉 `.mjs` 后缀。**
// 同族纪律 = 本仓「造故障时探针名必须全仓无命中」。
//
// 覆盖：绿档非空过 + 新增沉默致败 + D1 四条边界（同一行要求 / 闭集 / `.claude/hooks/` /
// 判定窗口 45 行）+ D2 三条要件 + 扫描面前缀 + shrink-only 的两类 stale + `--list` + 接线钉。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
  REPO_ROOT,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-gate-mount-declaration.mjs'

afterEach(cleanupGateFixtures)

/**
 * 从**闸源码活源**解析 KNOWN_SILENT。
 * ⛔ 别改成在本文件里维护一份清单 —— 那正是这条闸存在的理由的反面。
 * 解析不到 ⇒ 抛（fail-closed）：闸的形态变了应当先修本测试，而不是静默按空表跑。
 */
function knownSilentFromGate(): string[] {
  const src = readFileSync(resolve(REPO_ROOT, GATE), 'utf8')
  const block = src.match(/const KNOWN_SILENT = \[([\s\S]*?)\n\]/)
  if (!block) throw new Error('KNOWN_SILENT 解析失败 —— 闸的形态变了，先修本测试再跑')
  // 只取真正的数组项（行首缩进 + 引号）；被注释掉的历史行是 `  // '...'`，不匹配。
  const items = [...block[1].matchAll(/^\s*'([^']+)',/gm)].map((m) => m[1])
  if (items.length === 0) {
    // 表清空是这条闸声明的终态，但那时本测试的绿档 fixture 需要改写 —— 显式抛而不是静默。
    throw new Error('KNOWN_SILENT 已清空（终态）—— 本测试的绿档用例需要跟着改写')
  }
  return items
}

const KNOWN_SILENT = knownSilentFromGate()

/** 一份**沉默**的脚本内容：头 45 行里既不满足 D1 也不满足 D2。 */
const SILENT_BODY = '// placeholder — 沉默脚本，无挂载自声明\n'

type Overrides = {
  /**
   * 额外/覆盖的闸脚本：**仓库相对路径** → 内容。
   * ⚠️ 2026-08-26 起 key 带目录（`scripts/…` / `figma-sync/…`）—— 与闸的扫描面口径一致，
   * 这样用例能显式指定造在哪个目录，⛔ 别退回裸文件名 + 代码里拼 `scripts/`。
   */
  scripts?: Record<string, string>
  /** 从 fixture 里**去掉**的 KNOWN_SILENT 成员（造 stale 用），同样是仓库相对路径 */
  omit?: string[]
}

function build(o: Overrides = {}) {
  const omit = new Set(o.omit ?? [])
  const files: Record<string, string> = {}
  for (const f of KNOWN_SILENT) {
    if (omit.has(f)) continue
    // ⚠️ 2026-08-26 起 KNOWN_SILENT 的 key **已经是仓库相对路径**（`scripts/…` /
    //    `figma-sync/…`），⛔ 别再往前面拼 `scripts/` —— 那会造出 `scripts/scripts/…`。
    files[f] = SILENT_BODY
  }
  // key 已经是仓库相对路径（见 Overrides 的注释）⇒ 原样用，⛔ 别再拼目录
  for (const [rel, body] of Object.entries(o.scripts ?? {})) files[rel] = body
  // 扫描目录缺失时闸**刻意 fail-closed**（见闸里 SCAN_DIRS 那段），所以 fixture 必须把
  // 两个目录都建出来 —— 哪怕本用例一个 figma-sync 文件都不放。
  return createGateFixture({ gate: GATE, prefix: 'gate-mount-fx', files, dirs: ['figma-sync'] })
}

/** fixture 的基准读数：KNOWN_SILENT 全在 + 闸自己（有 D1 声明）。 */
const BASE_SCANNED = KNOWN_SILENT.length + 1

describe('audit:gate-mount-declaration — 绿档 + 自印非空过', () => {
  it('豁免表全命中、无新增沉默 → exit 0，且自印的扫描面是 fixture 自己的（≠ 真仓库 ⇒ 非空过）', () => {
    const run = runGate(build(), GATE)
    // 真仓库 scripts/ 下三前缀是 80+ 个；fixture 恰好 KNOWN_SILENT+1，跑错了树对不上。
    expectGateGreen(run, {
      contains: [
        `[audit:gate-mount-declaration] 扫描面 ${BASE_SCANNED}`,
        `沉默 ${KNOWN_SILENT.length} · 具名豁免 ${KNOWN_SILENT.length}`,
        `✅ PASS — 无未豁免的沉默脚本；${KNOWN_SILENT.length} 条具名豁免全部仍然命中（表只许缩）。`,
      ],
    })
  })

  it('闸自己被算进「已声明 D1」（它的头注释就是本仓的挂载真源范式）', () => {
    const run = runGate(build(), GATE)
    expectGateGreen(run, { contains: ['已声明 1（D1 1 / D2 0）'] })
  })
})

describe('audit:gate-mount-declaration — 判据：新增沉默脚本', () => {
  it('新 audit-* 无自声明且不在豁免表 → 红并点名它 + 给两种修法', () => {
    const run = runGate(build({ scripts: { 'scripts/audit-fx-brand-new.mjs': SILENT_BODY } }), GATE)
    expectGateRed(run, {
      marker: '个闸脚本的头注释没有挂载层自声明，且不在具名豁免表里：',
      checks: [
        '- scripts/audit-fx-brand-new.mjs',
        'D1 自有挂载：同一行写清层级与产物',
        'D2 经父闸挂载：写明没有自有挂载 + 点名父闸',
        '⛔ 别把新脚本加进 KNOWN_SILENT',
      ],
    })
  })

  it('⛔ must-not-hit：同一个新脚本补上 D1 声明 → 绿（致败源是缺声明，不是文件新）', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-brand-new.mjs': '//   L4 = .husky/pre-commit 的条件块\n' } }),
      GATE,
    )
    expectGateGreen(run, { contains: [`扫描面 ${BASE_SCANNED + 1}`, '已声明 2（D1 2 / D2 0）'] })
  })
})

describe('audit:gate-mount-declaration — D1 的四条边界', () => {
  it('⛔ 关键回归钉：层级与产物**不在同一行**不算 D1（这正是被推翻的那版散文判据）', () => {
    // 闸头注释逐字记着：跨行放宽就退回「头里出现过 L1–L5 就算」，而实测那样会给
    // 5 个只是在讲别的脚本 / 讲层级谱系的文件发通行证。本用例钉住这条收紧不许被放松。
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-two-lines.mjs': '// 这条闸是 L4 档的\n// 它跑在 pre-commit 里\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-two-lines.mjs'] })
  })

  it('⛔ must-not-hit：只有层级、没有具体产物路径 → 不算 D1', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-level-only.mjs': '// 本闸属于 L5 强度，别与 L1 规则混为一谈\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-level-only.mjs'] })
  })

  it('⛔ must-not-hit：挂载产物闭集之外的路径不算（如 .github/workflows/foo.yml）', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-not-in-set.mjs': '//   L5 = .github/workflows/foo.yml 的某 step\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-not-in-set.mjs'] })
  })

  it('`.claude/hooks/`（2026-08-25 新增的真实挂载类别）算 D1', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-hook.mjs': '//   L4 = .claude/hooks/post-figma-write.sh（PostToolUse）\n' } }),
      GATE,
    )
    expectGateGreen(run, { contains: ['已声明 2（D1 2 / D2 0）'] })
  })

  it('判定窗口 = 头 45 行：第 45 行的声明算', () => {
    const body = `${'// filler\n'.repeat(44)}//   L4 = .husky/pre-commit 条件块\n`
    const run = runGate(build({ scripts: { 'scripts/audit-fx-line45.mjs': body } }), GATE)
    expectGateGreen(run, { contains: ['已声明 2（D1 2 / D2 0）'] })
  })

  it('⛔ 判定窗口钉：第 46 行的声明**不**算（复盘 §5「头注释把自声明挤出判定窗口」）', () => {
    const body = `${'// filler\n'.repeat(45)}//   L4 = .husky/pre-commit 条件块\n`
    const run = runGate(build({ scripts: { 'scripts/audit-fx-line46.mjs': body } }), GATE)
    expectGateRed(run, { checks: ['- scripts/audit-fx-line46.mjs'] })
  })
})

describe('audit:gate-mount-declaration — D2 的三条要件', () => {
  it('「无自有挂载」+ 点名父闸 → 算 D2（audit-mockup-* 那批子闸的正常形态）', () => {
    const run = runGate(
      build({
        scripts: {
          'scripts/audit-fx-child.mjs': '//   经 audit-fx-parent-total.mjs 总闸挂载；⛔ 没有自己的 npm script\n',
        },
      }),
      GATE,
    )
    expectGateGreen(run, { contains: ['已声明 2（D1 1 / D2 1）'] })
  })

  it('⛔ must-not-hit：只点名父闸、没说「无自有挂载」→ 不算 D2', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-parent-only.mjs': '//   参见 audit-fx-parent-total.mjs 的实现\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-parent-only.mjs'] })
  })

  it('⛔ must-not-hit：只说「无自有挂载」、没点名父闸 → 不算 D2', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-noparent.mjs': '//   本脚本无自有挂载，手工跑\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-noparent.mjs'] })
  })

  it('⛔ must-not-hit：点名的「父闸」是自己 → 不算 D2（自引用不是挂载）', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-self.mjs': '//   无自有挂载 —— 详见 audit-fx-self.mjs 头部\n' } }),
      GATE,
    )
    expectGateRed(run, { checks: ['- scripts/audit-fx-self.mjs'] })
  })
})

describe('audit:gate-mount-declaration — 扫描面', () => {
  it('`smoke-` 前缀也在扫描面（沉默的新 smoke- 照样红）', () => {
    const run = runGate(build({ scripts: { 'scripts/smoke-fx-new.mjs': SILENT_BODY } }), GATE)
    expectGateRed(run, { checks: ['- scripts/smoke-fx-new.mjs'] })
  })

  it('`check-` 前缀也在扫描面', () => {
    const run = runGate(build({ scripts: { 'scripts/check-fx-new.mjs': SILENT_BODY } }), GATE)
    expectGateRed(run, { checks: ['- scripts/check-fx-new.mjs'] })
  })

  it('⛔ must-not-hit：三前缀之外的 .mjs 不扫（generate-* 不是闸）', () => {
    const run = runGate(build({ scripts: { 'generate-fx-thing.mjs': SILENT_BODY } }), GATE)
    expectGateGreen(run, { contains: [`扫描面 ${BASE_SCANNED}`] })
  })

  it('⛔ must-not-hit：非 .mjs 不扫（同名 .md / .ts 不算闸）', () => {
    const run = runGate(build({ scripts: { 'audit-fx-doc.md': SILENT_BODY, 'audit-fx-thing.ts': SILENT_BODY } }), GATE)
    expectGateGreen(run, { contains: [`扫描面 ${BASE_SCANNED}`] })
  })
})

describe('audit:gate-mount-declaration — shrink-only 的两类 stale', () => {
  it('豁免表里某条**补了声明** → 红，要求删那一行（表只许缩）', () => {
    const target = KNOWN_SILENT[0]
    const run = runGate(
      build({ scripts: { [target]: '//   L4 = .husky/pre-commit 条件块\n' } }),
      GATE,
    )
    expectGateRed(run, {
      marker: '条豁免已失效（对应脚本现在已有声明，或文件已删）—— 请从 KNOWN_SILENT 里删掉这些行：',
      checks: [`- ${target}`, '留着一条不再命中的豁免 = 给该文件留一个永久空洞'],
    })
  })

  it('豁免表里某条**文件已删** → 同样红（stale 的第二种形态）', () => {
    const target = KNOWN_SILENT[1]
    const run = runGate(build({ omit: [target] }), GATE)
    expectGateRed(run, { checks: [`- ${target}`, '请从 KNOWN_SILENT 里删掉这些行'] })
  })

  it('多条同时 stale → 全部逐条印出且计数正确', () => {
    const run = runGate(build({ omit: [KNOWN_SILENT[0], KNOWN_SILENT[1]] }), GATE)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('❌ 2 条豁免已失效')
    expect(run.stderr).toContain(`- ${KNOWN_SILENT[0]}`)
    expect(run.stderr).toContain(`- ${KNOWN_SILENT[1]}`)
  })
})

describe('audit:gate-mount-declaration — 接线钉（判据 → 退出码 → 点名输出）', () => {
  it('unexcused + stale 同时发生 → 两段都印、exit 1（接线不吞任一类）', () => {
    const run = runGate(
      build({ scripts: { 'scripts/audit-fx-brand-new.mjs': SILENT_BODY }, omit: [KNOWN_SILENT[0]] }),
      GATE,
    )
    expect(run.status).toBe(1)
    expect(run.stderr).toContain('没有挂载层自声明，且不在具名豁免表里')
    expect(run.stderr).toContain('- scripts/audit-fx-brand-new.mjs')
    expect(run.stderr).toContain('条豁免已失效')
    expect(run.stderr).toContain(`- ${KNOWN_SILENT[0]}`)
    // ⛔ 绿档那行不许同时出现 —— 恒真的 PASS 行是本仓「判据必须取终态事实」抓过的病
    expect(run.stdout).not.toContain('✅ PASS')
  })

  it('`--list` 模式 → 印沉默清单、exit 0（重生成豁免表用的口子，不是判据）', () => {
    const run = runGate(build(), GATE, ['--list'])
    expect(run.status).toBe(0)
    for (const f of KNOWN_SILENT) expect(run.stdout).toContain(`  '${f}',`)
    expect(run.stdout).not.toContain('✅ PASS')
  })

  it('`--list` 在有未豁免沉默脚本时**也**是 exit 0（它先于判据返回，如实登记）', () => {
    // ⚠️ 钉的是现行行为：`--list` 是生成口不是检查口。⛔ 别在 CI 里用它替代默认调用。
    const run = runGate(build({ scripts: { 'scripts/audit-fx-brand-new.mjs': SILENT_BODY } }), GATE, ['--list'])
    expect(run.status).toBe(0)
    expect(run.stdout).toContain("  'scripts/audit-fx-brand-new.mjs',")
  })

  it('扫描面只剩闸自己 → 豁免表整表 stale 并报满，⛔ 不是「没东西可查所以绿」', () => {
    // ⚠️ 用例名如实说清测的是什么：`createGateFixture` 必然建 scripts/（闸自己住那儿），
    // 所以这里造不出「目录缺失」，造出的是**分母塌到 1**。钉的是这种情形下闸仍红 ——
    // 分母塌了走的是 stale 判据，不是静默放行。目录真缺失的崩溃档在本仓其它闸的用例里。
    const root = createGateFixture({ gate: GATE, prefix: 'gate-mount-fx-bare', dirs: ['figma-sync'] })
    const run = runGate(root, GATE)
    expectGateRed(run, { marker: `❌ ${KNOWN_SILENT.length} 条豁免已失效` })
    expect(run.stdout).toContain('扫描面 1')
    expect(run.stdout).not.toContain('✅ PASS')
  })
})
