// tests/audit-gate-ci-parity-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-gate-ci-parity.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 选这条（候选集 = 「非随包 ∧ blocking ∧ 接线零覆盖 ∧ P2 干净 ∧ P3 非 TREE」，
//   ⛔ 条数与排序别抄 —— 跑 `pnpm report:gate-regression-face --json` 按 `mounts.length` 自己排）：
//   不是候选集里最便宜的那条（那是 `NAMED(2)` 的那几条），选它的理由是**爆炸半径**：
//
//   ① 它是「所有只挂 L4 的闸必须在 CI 里也跑得到」的**唯一**机械保障。它假绿 ⇒ 任意条闸
//      可以永久停在 L4-only，而 L4 一句 `--no-verify` 即失效 ⇒ **它的假绿会放大到全部闸的
//      强度**，不是某一份产物。它自己的头注释逐字写着：那 16 条 L4-only 归零是一次性动作、
//      规程不是。
//   ② 它正是 `report:gate-regression-face` 诚实边界 ① 指定的两个权威之一（「挂载层是否真的
//      挂上 → 本闸 + 那条挂载声明元闸」）。那一对里另一条早已是 `E-fx`，**本闸是零接线覆盖**
//      ⇒ 整个 [[INFRA-F138]] 体系（含挑候选用的那一整列「挂载」读数）此前靠一条没有接线
//      回归面的闸兜着。
//
// ⛔ 与 `tests/audit-gate-ci-parity.test.ts` 的分工（**别合并**）：那份 import
//   `stripComment` / `collectCalls` / `computeParity` 三个符号，覆盖**判据逻辑**（18 条，
//   其中 3 条另对真仓库现状断言）。本份 spawn 整个脚本，覆盖只有整脚本能看见的这些：
//     · 入口守卫（原始 `process.argv[1] === fileURLToPath(import.meta.url)`）→ CLI 块 →
//       `process.exit(failed ? 1 : 0)` 两个出口
//     · **`validateExemptions()` 与 `readWorkflows()` 根本没有 export** ⇒ 旧面结构上够不到
//     · **自钉判据**（`if (!a.includes(SELF))`）—— 只活在 CLI 块里
//     · `failed` 是**累加**不是 early-return：自钉失败与差集非空同时发生时两块都印
//     · `--list` 模式（`process.argv.includes('--list')` + 它在摘要**之前**短路）
//     · 五条 fail-closed 出口的**真实触发路径**（钩子文件缺失 / workflow 目录缺失 /
//       目录里没 .yml / 主链塌 / 入口被写回 `&&` 串）——旧面只能给 `computeParity` 喂参数，
//       喂不到「main 里从磁盘读这些」这一层
//     · 自印摘要块（A 条数 / B 条数 / workflow 文件清单）
//
// ⛔ **本面覆盖不到具名豁免表的三条分支**（`validateExemptions` 的畸形行 / 差集落在豁免里 /
//   stale 豁免）：`STRUCTURAL_L4_ONLY` 是模块顶层 const 且现为空表（闸头注释逐字写「今天是
//   空的 = 终态」），从 fixture 侧**不改闸**就造不出非空表，而改闸是 [[INFRA-F138]] 逐字封的路。
//   **如实登记的边界，不是待补 TODO** —— 与前几轮 `audit-rule-inventory` 的 S5 /
//   `audit-demo-css-page-scope` 的 S2 同型。
//
// 🔴 **顺带如实登记一处闸自印与实现不一致**（见下方 A4，**本轮没改闸**、报 owner）：
//   ~~自印那行写 `B = … prepublishOnly …`，实现展开的是 `CHAIN_SOT_KEY`~~ → ✅ **2026-08-28 owner 拍板已修**（实现改用变量插值）。原文留档：自印写的是 `prepublishOnly`，而实现（与它自己的
//   头注释）展开的是 `CHAIN_SOT_KEY` = `gate-chain`。A3 用行为把这一点钉死了。
//
// ⛔ 污染纪律（[[INFRA-F138]] 记的三种形态）：本文件除被测闸自身外**不出现任何别的闸的
//   `.mjs` basename、也不出现任何别的 `audit:` / `check:` / `lint:` / `smoke:` npm key**
//   —— 否则量具会把那条闸误报成「已覆盖」。fixture 里的脚本 / npm key / workflow 文件名
//   一律用 `fxp*` 假名（落地前 `grep -rF fxp` 全仓零命中）。本闸的 stderr 里硬编码了
//   `.gitea/workflows/pr-checks.yml`（默认修法提示），那是**它自己的字符串**、不是别的闸，
//   因此也**不能**拿它当「真仓库泄漏」的标记 —— 反向钉用的是下面那五个只在真仓库出现的读数。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'
// ⛔ 这不是「别的闸」——`gate-chain-steps.mjs` 是被测闸自己 import 的常量 lib，不是 audit 脚本，
//   量具不会把它误报成「已覆盖的闸」。取它是为了让下面那条钉与实现读同一个真源，
//   而不是在测试里把 key 名再写死一份（那正是当初出 bug 的形态）。
import { CHAIN_SOT_KEY } from '../scripts/lib/gate-chain-steps.mjs'

const GATE = 'scripts/audit-gate-ci-parity.mjs'
const PRE_COMMIT = '.husky/pre-commit'
const COMMIT_MSG = '.husky/commit-msg'
const PKG = 'package.json'
const GITEA_WF = '.gitea/workflows/fxp-pr.yml'
const GITHUB_WF = '.github/workflows/fxp-ci.yml'

afterAll(() => cleanupGateFixtures())

// ── 反向钉素材 ──────────────────────────────────────────────────────────────
// 真仓库现取（`node scripts/audit-gate-ci-parity.mjs` 自印第一行 + B 行，2026-08-27）：
//   husky 钩子上的闸 51 条 · CI 可达 73 条 · 差集 0 条 · 具名豁免 0 条
//   B = … ∪ 5 份 workflow（… / .gitea/workflows/publish.yml / … / .github/workflows/stale-anchors.yml）
// fixture 一律是 4 / 5 / 2 份 ⇒ 「跑错了树（闸退回真仓库读）」结构上不可能同时对上。
const REAL_REPO_MARKERS = [
  'husky 钩子上的闸 51 条',
  'CI 可达 73 条',
  '5 份 workflow',
  '.gitea/workflows/publish.yml',
  '.github/workflows/stale-anchors.yml',
]

/** 断言输出里一个真仓库读数都没泄漏进来。⚠️ 调用处必须另配一个正向锚点。 */
function expectNoRealRepoLeak(out: string): void {
  for (const m of REAL_REPO_MARKERS) expect(out).not.toContain(m)
}

/** 最后一个非空行 —— 取**终态事实**用（排除「碰巧崩在同一个退出码上」）。 */
function lastLine(s: string): string {
  const lines = s.split('\n').filter((l) => l.trim() !== '')
  return lines[lines.length - 1] ?? ''
}

const GREEN_LAST_LINE = '豁免表为空 = 终态（不是待办）。'
const RED_LAST_LINE = '才加进 STRUCTURAL_L4_ONLY，且四项 script/since/why/fix 必须写全。'

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 判据是**调用位置**，不是「文件里提到过」。这一行是常驻的负向对照：路径出现在 `grep -qE`
// 的触发面里（那是「改了它就重跑」的触发器），若判据被放宽成「提到过」，A 会多出一条
// `scripts/fxp-trigger-face-only.mjs` ⇒ 下面 A1 断言的 `4 条` 当场对不上。
const TRIGGER_FACE_LINE =
  "if git diff --cached --name-only | grep -qE 'scripts/fxp-trigger-face-only\\.mjs'; then"

type FixtureOpts = {
  /** 追加到 `.husky/pre-commit` 的行 */
  preCommitExtra?: string[]
  /** 追加到 `.husky/commit-msg` 的行 */
  commitMsgExtra?: string[]
  /** pre-commit 里**不写**本闸自己 ⇒ 触发自钉判据 */
  omitSelfNail?: boolean
  /** 两个钩子都只有 `echo` ⇒ A 侧解析结果为空 */
  hooksWithoutGates?: boolean
  /** 追加到两侧 workflow 的 `run:` 行 */
  giteaExtra?: string[]
  /** `scripts['gate-chain']` 的值（步骤面真源） */
  chain?: string
  /** `scripts.prepublishOnly` 的值（链的入口） */
  entry?: string
  /** 额外 npm scripts */
  scriptsExtra?: Record<string, string>
  /** 两侧 workflow 都写成不含任何调用的形态 ⇒ B 侧解析结果为空 */
  workflowsWithoutCalls?: boolean
  omit?: 'commit-msg' | 'gitea-dir' | 'package.json' | 'gate-chain'
  /** `.github/workflows` 目录建出来但**不放** .yml */
  githubDirEmpty?: boolean
}

function fixture(opts: FixtureOpts = {}): string {
  const preCommit = opts.hooksWithoutGates
    ? ['#!/usr/bin/env sh', 'echo "no gates here"', '']
    : [
        '#!/usr/bin/env sh',
        '# 注释里提到 node scripts/fxp-comment-only.mjs 也不算调用（词首 # 起注释）',
        TRIGGER_FACE_LINE,
        '  node scripts/fxp-alpha.mjs',
        'fi',
        ...(opts.omitSelfNail ? [] : [`node ${GATE}`]),
        'pnpm run fxp:beta',
        ...(opts.preCommitExtra ?? []),
        '',
      ]

  const commitMsg = opts.hooksWithoutGates
    ? ['#!/usr/bin/env sh', 'echo "no gates here"', '']
    : ['#!/usr/bin/env sh', 'node scripts/fxp-msg.mjs', ...(opts.commitMsgExtra ?? []), '']

  const scripts: Record<string, string> = {
    'fxp:beta': 'node scripts/fxp-beta.mjs',
    ...(opts.scriptsExtra ?? {}),
  }
  if (opts.omit !== 'gate-chain') scripts['gate-chain'] = opts.chain ?? 'node scripts/fxp-chain-only.mjs'
  scripts.prepublishOnly = opts.entry ?? 'node scripts/run-gate-chain.mjs'

  const giteaYml = opts.workflowsWithoutCalls
    ? ['name: fxp-pr', 'jobs:', '  gates:', '    steps:', '      - run: echo nothing-here', '']
    : [
        'name: fxp-pr',
        'on:',
        '  pull_request:',
        '  push:',
        '    branches: [master]',
        'jobs:',
        '  gates:',
        '    steps:',
        // 常驻负向对照：**注释里**提到一条闸不算接线。判据若被放宽，B 会多一条
        // ⇒ A1 断言的 `CI 可达 5 条` 当场对不上（不是靠 not.toContain 空过）。
        '      # 叙述性提及：node scripts/fxp-comment-only.mjs',
        `      - run: node ${GATE}`,
        '      - run: node scripts/fxp-alpha.mjs',
        '      - run: pnpm run fxp:beta',
        ...(opts.giteaExtra ?? []),
        '',
      ]

  const githubYml = opts.workflowsWithoutCalls
    ? ['name: fxp-ci', 'jobs:', '  t:', '    steps:', '      - run: echo nothing-here', '']
    : ['name: fxp-ci', 'on: [push]', 'jobs:', '  t:', '    steps:', '      - run: node scripts/fxp-msg.mjs', '']

  const files: Record<string, string> = {
    [PRE_COMMIT]: preCommit.join('\n'),
    [GITHUB_WF]: githubYml.join('\n'),
  }
  if (opts.omit !== 'commit-msg') files[COMMIT_MSG] = commitMsg.join('\n')
  if (opts.omit !== 'package.json') files[PKG] = `${JSON.stringify({ name: 'fxp-fixture', scripts }, null, 2)}\n`
  if (opts.omit !== 'gitea-dir') files[GITEA_WF] = giteaYml.join('\n')
  if (opts.githubDirEmpty) delete files[GITHUB_WF]

  return createGateFixture({
    gate: GATE,
    prefix: 'gate-ci-parity-fx',
    // `.github/workflows` 空目录那一档要目录在、.yml 不在；`.gitea` 侧同理由不建目录。
    dirs: opts.githubDirEmpty ? ['.github/workflows'] : [],
    files,
    // 闸 `import { CHAIN_SOT_KEY, CHAIN_ENTRY_KEY } from './lib/gate-chain-steps.mjs'`
    // ⇒ 用 harness 的默认软链（那批 lib 不自己算 REPO_ROOT，软链是安全的）。
    linkDirs: ['scripts/lib'],
  })
}

// ═══════════════════════════════════════════════════════════════════════════
describe('audit-gate-ci-parity CLI — 绿档（接线走到 exit 0）', () => {
  it('干净 fixture ⇒ exit 0，且三个读数全是 **fixture 自己的**（A / B / workflow 清单三个不同输入面）', () => {
    const root = fixture()
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: [
        // A=4 来自两个钩子 + package.json 的 `fxp:beta` 展开；B=5 来自 gate-chain + 两份
        // workflow；`2 份 workflow（…）` 来自两次 readdir ⇒ 三个数各出自不同输入面。
        '[audit:gate-ci-parity] husky 钩子上的闸 4 条 · CI 可达 5 条 · 差集 0 条 · 具名豁免 0 条',
        `∪ 2 份 workflow（${GITEA_WF} / ${GITHUB_WF}）`,
        '✅ PASS — husky 钩子上没有 CI 跑不到的闸；0 条具名豁免全部仍然命中（表只许缩）。',
      ],
    })
    expectNoRealRepoLeak(run.stdout)
  })

  it('绿档的**终态事实**：闸自己最后那句话就是 stdout 的最后一行（排除「碰巧崩在 exit 0」）', () => {
    const root = fixture()
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(lastLine(run.stdout).trim()).toBe(GREEN_LAST_LINE)
    expect(run.stderr).toBe('')
  })

  it('B 侧展开的是**步骤面真源 `gate-chain`**：唯一可达路径只在链里 ⇒ 仍绿', () => {
    // `fxp-chain-only.mjs` 上了 pre-commit，而两侧 workflow 都不提它 —— 它唯一的 CI 可达
    // 路径是 `scripts['gate-chain']`。若 B 只展开入口（`prepublishOnly` = runner），
    // 这条会落进差集 ⇒ 红。绿 = 实现确实从 CHAIN_SOT_KEY 展开。
    const root = fixture({ preCommitExtra: ['node scripts/fxp-chain-only.mjs'] })
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: ['husky 钩子上的闸 5 条 · CI 可达 5 条 · 差集 0 条'],
    })
    expectNoRealRepoLeak(run.stdout)
  })

  it('自印那行说的 B 来源与实现读的 key 一致（2026-08-28 修，此前它写 `prepublishOnly`）', () => {
    // 沿革：2026-08-25 主链字符串从 `prepublishOnly` 搬到 `gate-chain`，这行自印没跟着搬 ⇒
    // 连续多轮宣称展开的是 `prepublishOnly`，而实现读的一直是 `CHAIN_SOT_KEY`。
    // 第五十七轮查实后用两条钉住现行行为并**逐字标注不是背书**，把改法交给 owner；
    // owner 2026-08-28 拍板修，实现改用 `CHAIN_SOT_KEY` 变量插值，本条随之翻转方向。
    //
    // ⛔ 判据故意取**两端一致**而不是写死 `gate-chain` 三个字：写死等于把 key 名在这里
    // 再复制一份，正是当初出这个 bug 的形态。key 再搬一次时，实现与本条会一起跟着走。
    const root = fixture()
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(run.stdout).toContain(`B = package.json 的 ${CHAIN_SOT_KEY} 字符串递归展开`)
    // 反向锚点：旧的那个 key 名不得再出现在这句自印里（否则等于 bug 复发）。
    expect(run.stdout).not.toContain('B = package.json 的 prepublishOnly 字符串递归展开')
    // 正向锚点：同一次运行里，行为侧的凭据（`gate-chain` 的那条闸不在差集里）也成立。
    expect(run.stdout).toContain('差集 0 条')
  })

  it('钩子里的 `pnpm run <key>` 按 package.json 递归展开后与 CI 侧的 `node …` 对得上 ⇒ 绿', () => {
    // pre-commit 写 `pnpm run fxp:beta`，workflow 写 `pnpm run fxp:beta`；两侧都得先展开成
    // `scripts/fxp-beta.mjs` 才可能相等 —— 这条同时是「A 侧真的拿到了 package.json」的凭据。
    const root = fixture()
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['差集 0 条'] })
    // 差集为空 ⇒ 展开成功；⚠️ 配正向锚点（上一行）后这条 must-not-hit 才不是空过。
    expect(run.stderr).not.toContain('scripts/fxp-beta.mjs')
  })
})

// ═══════════════════════════════════════════════════════════════════════════
describe('audit-gate-ci-parity CLI — 红档（接线走到 exit 1）', () => {
  it('一条闸只挂 pre-commit、CI 里一条路径都跑不到 ⇒ exit 1 + 点名 + 默认修法提示', () => {
    const root = fixture({ preCommitExtra: ['node scripts/fxp-only-hooked.mjs'] })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '❌ 1 条闸只挂在 husky 钩子上，CI 里一条路径都跑不到（= L4-only，一句 --no-verify 即失效）：',
      checks: [
        '   - scripts/fxp-only-hooked.mjs',
        '默认修法 = **接线**',
        '`node scripts/fxp-only-hooked.mjs`',
        '⛔ 接线前先无参实跑一次',
      ],
    })
    expectNoRealRepoLeak(run.stdout + run.stderr)
  })

  it('红档的**终态事实**：stderr 最后一行是闸自己的末句，不是崩溃栈尾', () => {
    const root = fixture({ preCommitExtra: ['node scripts/fxp-only-hooked.mjs'] })
    const run = runGate(root, GATE)

    expect(run.status).toBe(1)
    expect(lastLine(run.stderr).trim()).toBe(RED_LAST_LINE)
  })

  it('只挂**第二个钩子** `commit-msg` 的闸同样进差集（A 是钩子家族，不只 pre-commit）', () => {
    const root = fixture({ commitMsgExtra: ['node scripts/fxp-msg-only.mjs'] })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['   - scripts/fxp-msg-only.mjs'] })
    // 必不中对照：pre-commit 上那批全部接了 CI ⇒ 差集里只有 commit-msg 那条
    expect(run.stderr).toContain('❌ 1 条闸只挂在')
  })

  it('`pnpm run <key>` 只在钩子上 ⇒ 差集里是**展开后的脚本路径**，不是 npm key 名', () => {
    const root = fixture({
      preCommitExtra: ['pnpm run fxp:gamma'],
      scriptsExtra: { 'fxp:gamma': 'node scripts/fxp-gamma.mjs' },
    })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['   - scripts/fxp-gamma.mjs'] })
    // 正向锚点在上一行；这条钉的是「点名的粒度是脚本不是 key」（豁免表的键也是脚本路径）。
    expect(run.stderr).not.toContain('   - fxp:gamma')
  })

  it('`figma-sync/` 形态的闸同样进差集（NODE_CALL_RE 的第二个路径前缀）', () => {
    const root = fixture({ preCommitExtra: ['node figma-sync/fxp-sync-thing.mjs'] })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['   - figma-sync/fxp-sync-thing.mjs'] })
  })

  it('⛔ workflow 里只在**注释**里提到 ⇒ 仍进差集（整脚本路径下 stripComment 真的接上了）', () => {
    // 常驻的 `# 叙述性提及：node scripts/fxp-comment-only.mjs` 那行现在有了 A 侧的对手。
    const root = fixture({ preCommitExtra: ['node scripts/fxp-comment-only.mjs'] })
    const run = runGate(root, GATE)

    expectGateRed(run, { checks: ['   - scripts/fxp-comment-only.mjs'] })
  })

  it('自钉判据：pre-commit 里没有本闸自己 ⇒ 红（**即使差集为空**）', () => {
    // 本闸仍在 gitea workflow 里 ⇒ 它在 B 里、不在 A 里 ⇒ 差集照样是 0。
    // 这一档只有自钉判据抓得到，而自钉判据只活在 CLI 块里。
    const root = fixture({ omitSelfNail: true })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: `❌ 自钉判据失败：本闸（${GATE}）没有出现在 A 里。`,
      checks: [
        '① 它被从 .husky/pre-commit 摘掉了',
        '② A 的解析坏了（pre-commit 改了调用形态 / 正则失配）—— 那样差集会静默变 0。',
        '差集 0 条',
      ],
    })
    // 必不中对照（配上面的正向锚点）：这一档不该报「L4-only」那一块。
    expect(run.stderr).not.toContain('条闸只挂在 husky 钩子上')
  })

  it('自钉失败 + 差集非空**同时**发生 ⇒ 两块都印（`failed` 是累加，不是 early-return）', () => {
    const root = fixture({ omitSelfNail: true, preCommitExtra: ['node scripts/fxp-only-hooked.mjs'] })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      checks: [
        '❌ 自钉判据失败',
        '❌ 1 条闸只挂在 husky 钩子上',
        '   - scripts/fxp-only-hooked.mjs',
      ],
    })
    // 终态事实：末行仍是「L4-only」那块的末句 ⇒ 两块的顺序与出口都没被前一块截断。
    expect(lastLine(run.stderr).trim()).toBe(RED_LAST_LINE)
  })
})

// ═══════════════════════════════════════════════════════════════════════════
describe('audit-gate-ci-parity CLI — 五条 fail-closed 出口的真实触发路径', () => {
  // ⚠️ 这一组钉的是**崩溃**不是判据红：这些出口都是 `throw new Error(...)`、无 catch，
  //    node 打栈退 1。⛔ 所以刻意**不用** `expectGateRed`（那会把崩溃当成「判据开火了」=
  //    harness 判据 2 逐字警告的假红）。判据取两个终态事实：
  //      ① `stdout === ''` —— 五条全在第一次 `console.log` 之前 ⇒ 确实死在读输入这一层
  //      ② stderr 里是**那一条**具体消息（互相区分得开，不是「反正红了」）
  const expectCrash = (run: { status: number; stdout: string; stderr: string }, msg: string) => {
    expect(run.status).toBe(1)
    expect(run.stdout).toBe('')
    expect(run.stderr).toContain(msg)
    expectNoRealRepoLeak(run.stderr)
  }

  it(`缺 ${COMMIT_MSG} ⇒ 抛（列进 L4_HOOKS 的钩子被摘掉正是该响的那一刻）`, () => {
    const root = fixture({ omit: 'commit-msg' })
    const run = runGate(root, GATE)

    expectCrash(run, '.husky/commit-msg 不存在 —— 无法计算 A（它在 L4_HOOKS 里）')
  })

  it('缺 `.gitea/workflows` 整个目录 ⇒ 抛（半条 CI 面消失，不当「那边本来就没闸」）', () => {
    const root = fixture({ omit: 'gitea-dir' })
    const run = runGate(root, GATE)

    expectCrash(run, 'workflow 目录不存在：.gitea/workflows')
  })

  it('`.github/workflows` 在、里面没有 .yml ⇒ 抛（与「目录不存在」区分得开）', () => {
    const root = fixture({ githubDirEmpty: true })
    const run = runGate(root, GATE)

    expectCrash(run, 'workflow 目录里没有 .yml：.github/workflows')
    expect(run.stderr).not.toContain('workflow 目录不存在')
  })

  it('package.json 缺 `gate-chain` ⇒ 抛（CI 可达面的主链塌了）', () => {
    const root = fixture({ omit: 'gate-chain' })
    const run = runGate(root, GATE)

    expectCrash(run, 'package.json 没有非空的 scripts.gate-chain —— CI 可达面的主链塌了')
  })

  it('入口 `prepublishOnly` 被写回 `&&` 串 ⇒ 抛（悄悄退回 fail-fast 的唯一响声）', () => {
    const root = fixture({
      entry: 'pnpm run fxp:beta && pnpm run fxp:gamma',
      scriptsExtra: { 'fxp:gamma': 'node scripts/fxp-gamma.mjs' },
    })
    const run = runGate(root, GATE)

    expectCrash(run, '链的入口被改回 fail-fast 形态')
    // 正向锚点：报错里带上当前值，改的人不用去翻文件
    expect(run.stderr).toContain('pnpm run fxp:beta && pnpm run fxp:gamma')
  })

  it('两个钩子里一条闸调用都没解析到 ⇒ 抛（不是「没有 L4-only 闸」）', () => {
    const root = fixture({ hooksWithoutGates: true })
    const run = runGate(root, GATE)

    expectCrash(run, 'husky 钩子里一条闸调用都没解析到 —— 判定面塌了（不是「没有 L4-only 闸」）')
  })

  it('B 侧解析结果为空（链与两侧 workflow 都不含调用）⇒ 抛（与 A 侧那条区分得开）', () => {
    const root = fixture({ chain: 'echo no-gates-in-chain', workflowsWithoutCalls: true })
    const run = runGate(root, GATE)

    expectCrash(run, 'CI 可达面解析结果为空 —— 判定面塌了')
    expect(run.stderr).not.toContain('husky 钩子里一条闸调用都没解析到')
  })
})

// ═══════════════════════════════════════════════════════════════════════════
describe('audit-gate-ci-parity CLI — `--list` 模式（只有整脚本看得见）', () => {
  it('差集非空 ⇒ 印豁免行模板、exit 0，且**在摘要之前短路**（不印摘要也不印 FAIL 块）', () => {
    const root = fixture({ preCommitExtra: ['node scripts/fxp-only-hooked.mjs'] })
    const run = runGate(root, GATE, ['--list'])

    expect(run.status).toBe(0)
    expect(run.stdout).toContain(
      "  { script: 'scripts/fxp-only-hooked.mjs', since: 'YYYY-MM-DD', why: '结构上为什么上不了 CI', fix: '什么条件满足后接线' },",
    )
    // 上一行是正向锚点；这两条 must-not-hit 钉的是「短路发生在摘要与判据之前」——
    // 否则 `--list` 会既印模板又把仓库判红，那是两个矛盾的出口。
    expect(run.stdout).not.toContain('husky 钩子上的闸')
    expect(run.stderr).toBe('')
  })

  it('差集为空 ⇒ `--list` 同样短路：exit 0，且 stdout 恰好是**一个换行**（不是零字节）', () => {
    const root = fixture()
    const run = runGate(root, GATE, ['--list'])

    expect(run.status).toBe(0)
    // ⚠️ **这一条初版写的是 `stdout.trim() === ''`，那是空过**：注入「入口守卫恒假」后闸
    //    整个不执行 ⇒ `EXIT=0` + stdout 零字节 ⇒ 那个断言照样绿（2026-08-27 实测：注入 ①
    //    下 23 条只有这一条没红）。本档全是 must-not-hit，唯一「只有真跑过才有的读数」就是
    //    `console.log('')` 打出的那**一个换行** —— 精确到字节地区分「跑了但没有行要印」与
    //    「压根没跑」。⛔ 别改回 `.trim()`。
    expect(run.stdout).toBe('\n')
    expect(run.stderr).toBe('')
  })
})

// ═══════════════════════════════════════════════════════════════════════════
describe('audit-gate-ci-parity CLI — 输入面全随 fixture 走（无 fallback 回真仓库的凭据）', () => {
  it(`缺 ${PKG} ⇒ ENOENT 且路径指向 fixture，⛔ 不退回真仓库、⛔ 不当「读不到就通过」`, () => {
    const root = fixture({ omit: 'package.json' })
    const run = runGate(root, GATE)

    expect(run.status).not.toBe(0)
    expect(run.stderr).toContain('ENOENT')
    expect(run.stderr).toContain(`${root}/${PKG}`)
    expect(run.stdout).toBe('')
    expectNoRealRepoLeak(run.stderr)
  })
})
