// tests/audit-page-recipes-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-page-recipes.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 选这条（候选集 = 「非随包 ∧ blocking ∧ 接线零覆盖 ∧ P2 干净 ∧ P3 ∈ {NAMED,DIRLIST}」，
//   ⛔ 条数与排序别抄 —— 跑 `pnpm report:gate-regression-face --json` 按 `mounts.length` 自己排）：
//   **候选集里挂载最深、且是唯一的三层** —— L4 `.husky/pre-commit` + L5
//   `.gitea/workflows/pr-checks.yml` + L5 `package.json` 的 `gate-chain`。
//
//   ⚠️ 上一轮交接把它判成「成本高、不划算」，两条理由本轮**各量了一遍，一条真一条假**：
//     · ✅ 真：它 `import Ajv2020 from 'ajv/dist/2020.js'`，fixture 里 bare specifier
//       解析不到 ⇒ 实测 `ERR_MODULE_NOT_FOUND`、**exit 1 且 stdout 零字节**。
//       解法是本文件下方 `linkDirs` 里那**一个**元素，不是「多软链」。
//     · ❌ 假：「S4 依赖 `dist/` 而纪律 ⛔ 不软链 dist」—— 禁的是**软链真仓库那份**
//       （并行线会重建它 ⇒ 报假缺陷）。fixture 里**自己写**一份 `dist/style.css` 与它无关，
//       实测 S4 的红 / 绿 / SKIPPED 三态全可造（见下方 describe B）。
//
//   🔴 **更要紧的是 S4 那一层的形态：它是一个 fail-open 出口，而它开不开由环境决定。**
//   `readShippedTokens()` 取不到 `dist/style.css` 时返回 `null`，`validateShippedTokens`
//   直接 `{ skipped: true, ok: true }` ⇒ **exit 0**。`dist/` 是 gitignored 构建产物：
//   主仓 build 过 ⇒ S4 真判；**worktree 里没有 `dist/` ⇒ 同一条闸同一份数据走的是 SKIPPED
//   分支**（2026-08-27 两棵树各跑一次实测，逐字不同）。⇒ 「它每次 commit 都跑所以有覆盖」
//   在这一层上不成立 —— 跑到的是哪一支取决于谁的机器上有 dist。fixture 是唯一能把三态
//   都钉住的地方。这是 [[INFRA-F138]] 记的「真仓库数据只覆盖一条路径」那种形态的又一实例，
//   变体是**分叉不在数据里、在环境里**。
//
// ⛔ 与 `tests/audit-page-recipes.test.ts` 的分工（**别合并**）：那份 import
//   `validateShape` / `validateComponentRefs` / `validateLayoutTokens` /
//   `validateShippedTokens` / `validateResponsivePositions` 等 10 个符号，覆盖**判据逻辑**
//   （43 条）。本份 spawn 整个脚本，覆盖只有整脚本能看见的六样：
//     · 入口守卫（原始 `resolve(argv[1]) === resolve(fileURLToPath(import.meta.url))`）
//       → CLI 块 → `process.exit(failed ? 1 : 0)` 两个出口
//     · 三个 `read*()` 被**不带参数**调用 ⇒ root 落在 `REPO_ROOT`（旧面一律显式传 root
//       或直接读真仓库，对「main 里把根算错了」结构上零敏感）
//     · `failed` 是**累加**不是 early-return：S1 红时 S2–S5 的绿行照印
//     · S4 的 SKIPPED **分支印什么**（旧面只断言 `{skipped:true}` 这个返回值，
//       不覆盖 CLI 里那三行警告 + 摘要行的 `dist 未构建` 措辞 + 「不算失败」这个决定）
//     · `resolve(REPO_ROOT, RECIPES_PATH)` / `SCHEMA_PATH` 的实际解析（见 describe D）
//     · 自印摘要块（recipe ids / 三个分母 / S5 覆盖行）
//
// ⛔ 本面**覆盖不到 S1 的 schema 本体**：fixture 的 schema 是从活源
//   `figma-data/page-recipes.schema.json` **拷**进去的（`copyFiles`，harness 对不存在的
//   路径当场抛 ⇒ fail-closed）。这是**有意的**：S1 测的必须是真 schema，否则测的是我自己
//   写的一份副本。代价是**改活源 schema 会让本面的绿档红** —— 那是设计意图，它同时是
//   schema 的漂移钉（同 `audit-token-contract` 内联 generator `HEADER`、
//   `docs-section-anchor` 内联 `slugifySectionTitle` 的既有范式）。
//
// ⛔ 污染纪律（[[INFRA-F138]] 记的三种形态）：本文件除被测闸自身外**不出现任何别的闸的
//   `.mjs` basename、也不出现任何别的 `audit:` / `check:` / `lint:` / `smoke:` npm key**
//   —— 否则量具会把那条闸误报成「已覆盖」。本闸的 stdout 只引用它自己的输入路径
//   （`figma-data/page-recipes.json` 等）与 `pnpm build` / `prepublishOnly`（都不在闸清单
//   的 key 扫描面）⇒ 这里不需要「断言截短」那一招。fixture 的组件名 / token / recipe id
//   一律用 `Fx*` / `--fx-*` / `fx-*` 假名，落地前逐个 `git grep` 核过全仓零命中。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-page-recipes.mjs'
const RECIPES = 'figma-data/page-recipes.json'
const SCHEMA = 'figma-data/page-recipes.schema.json'
const VARIABLES = 'src/tokens/variables.css'
const SHIPPED = 'dist/style.css'

afterAll(() => cleanupGateFixtures())

// ── 反向钉素材 ──────────────────────────────────────────────────────────────
// 真仓库现取（`node scripts/audit-page-recipes.mjs` 自印那行）：
//   41 个可消费组件名 · 305 个源码 token 声明 · 359 个 dist token 声明 ·
//   3 recipe(s): data-table-page, entity-form-page, master-detail-page
// fixture 一律用 3 / 3 / 2 与 1 条假 recipe ⇒ 绿档钉的是 fixture 自己那几个数，
// 「跑错了树（退回真仓库读）」在这里结构上不可能同时对上两侧。
const REAL_REPO_MARKERS = [
  '41 个可消费组件名',
  '305 个源码 token 声明',
  '359 个 dist token 声明',
  'data-table-page',
  'entity-form-page',
  'master-detail-page',
]

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

// ── fixture 素材 ────────────────────────────────────────────────────────────
// S2 的分母 = 两个 barrel 的**并集**，两侧各有独有项 ⇒ 只读其中一个 barrel 就会漏。
const CANONICAL_BARREL = [
  `export { default as FxAlphaPanel } from './FxAlphaPanel.vue'`,
  // canonical 独有 —— 真仓库里扮演这个角色的是 Chart（经 ./chart 子路径分发）
  `export { default as FxGammaChart } from './FxGammaChart.vue'`,
  '',
].join('\n')

const ENTRY_BARREL = [
  'export {',
  '  FxAlphaPanel,',
  // 包入口独有 —— 真仓库里扮演这个角色的是 PillCounter / Logo
  '  FxBetaCard,',
  // 小写开头 ⇒ 被 /^[A-Z][A-Za-z0-9]*$/ 过滤掉，不进分母
  '  fxLowercaseOnly,',
  '}',
  // `export type {…}` 块刻意不匹配（正则要求 export 后紧跟 `{`）
  `export type { FxSomeType } from './types'`,
  '',
].join('\n')

const VARIABLES_CSS = [
  ':root {',
  '  --fx-gap: 8px;',
  '  --bp-fx-narrow: 900px;',
  // 定义了、但不属 --bp-* 组 ⇒ S5 (b) 的后半用它
  '  --fx-not-a-breakpoint: 4px;',
  '}',
  '',
].join('\n')

/** dist 里的 CSS 是压过的 —— 刻意不带行首缩进，钉住 S4 那条无 `^\s*` 的正则。 */
const SHIPPED_CSS_BOTH = ':root{--fx-gap:8px;--bp-fx-narrow:900px}'
const SHIPPED_CSS_NO_GAP = ':root{--bp-fx-narrow:900px}'
const SHIPPED_CSS_NO_BP = ':root{--fx-gap:8px}'

type Slot = Record<string, unknown>

function mainSlot(over: Slot = {}): Slot {
  return {
    slot: 'main',
    required: true,
    component: 'FxAlphaPanel',
    position: 'center',
    responsive: { stacksBelow: '--bp-fx-narrow', stackedPosition: 'top' },
    description: '主区域，只存在于 fixture 里。',
    ...over,
  }
}

function fillerSlot(over: Slot = {}): Slot {
  return {
    slot: 'filler',
    required: false,
    component: null, // 纯布局占位 —— S2 必须放行
    position: 'below main',
    description: '纯布局占位，内容由消费方 app 决定。',
    ...over,
  }
}

function recipe(over: Record<string, unknown> = {}) {
  return {
    id: 'fx-alpha-page',
    summary: '一个只存在于 fixture 里的假配方，用来钉整脚本的接线。',
    slots: [mainSlot(), fillerSlot()],
    states: [{ state: 'ready', when: '数据已加载完毕', driven_by: 'prop:loading' }],
    behaviorOwnedByApp: true,
    layoutTokens: { gap: 'var(--fx-gap)' },
    ...over,
  }
}

function recipesFile(recipes: unknown[]): string {
  return JSON.stringify(
    {
      _meta: {
        title: 'fixture page recipes',
        purpose: '给整脚本回归面用的假配方文件，不是任何真源的镜像。',
        is_source_of_truth: true,
        schema_note: '结构由活源 schema 校验；本文件只在 fixture 里存在。',
      },
      recipes,
    },
    null,
    2,
  )
}

type FixtureOpts = {
  recipes?: unknown[]
  /** 不传 = 不写 `dist/style.css`（S4 走 SKIPPED 分支） */
  shipped?: string
  /** 显式省略某个输入文件 —— describe D 用 */
  omit?: 'recipes' | 'schema' | 'variables'
}

function fixture(opts: FixtureOpts = {}): string {
  const files: Record<string, string> = {
    'src/canonical/index.ts': CANONICAL_BARREL,
    'src/index.ts': ENTRY_BARREL,
  }
  if (opts.omit !== 'variables') files[VARIABLES] = VARIABLES_CSS
  if (opts.omit !== 'recipes') files[RECIPES] = recipesFile(opts.recipes ?? [recipe()])
  if (opts.shipped !== undefined) files[SHIPPED] = opts.shipped

  return createGateFixture({
    gate: GATE,
    prefix: 'page-recipes-fx',
    files,
    // ⛔ 本闸不碰 `scripts/lib` ⇒ 显式关掉 harness 的默认软链；`node_modules` 是它
    //    `import 'ajv/dist/2020.js'` 的唯一解 —— 不软链 ⇒ ERR_MODULE_NOT_FOUND、零输出。
    linkDirs: ['node_modules'],
    // schema 拷活源（fail-closed：路径不存在 harness 当场抛）。见文件头「有意的漂移钉」。
    copyFiles: opts.omit === 'schema' ? [] : [SCHEMA],
  })
}

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

const GREEN_LAST_LINE_TAIL = '拦的是"说了会变却没说在哪变"，拦不了整条漏写）'
const RED_LAST_LINE_TAIL = '若某条判据本身写错了，改 schema/闸，不要改数据去凑绿（tail wagging the dog）。'

describe('audit-page-recipes CLI — 绿档（接线走到 exit 0）', () => {
  it('干净 fixture ⇒ exit 0，五条判据全绿，且三个分母读数全是 **fixture 自己的**', () => {
    const root = fixture({ shipped: SHIPPED_CSS_BOTH })
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: [
        '✓ S1 schema 结构',
        '✓ S2 slots[].component → 包入口/canonical barrel 公开导出',
        `✓ S3 layoutTokens → ${VARIABLES}`,
        `✓ S4 layoutTokens + responsive.stacksBelow → ${SHIPPED}`,
        '✓ S5 响应式改位机读',
        // 三个分母各来自一个**不同的**输入文件 ⇒ 三个同时对上，结构上不可能是退回真仓库读
        '3 个可消费组件名 · 3 个源码 token 声明 · 2 个 dist token 声明',
      ],
    })
    expectNoRealRepoLeak(run.stdout)
  })

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

    expect(run.status).toBe(0)
    expect(lastLine(run.stdout).endsWith(GREEN_LAST_LINE_TAIL)).toBe(true)
    expect(run.stderr).toBe('')
  })

  it('S2 分母是两个 barrel 的**并集**：canonical 独有与包入口独有的组件名都被接受', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [
        recipe({
          slots: [
            mainSlot({ component: 'FxGammaChart' }), // 只在 canonical barrel 里
            fillerSlot({ component: 'FxBetaCard' }), // 只在包入口 barrel 里
          ],
        }),
      ],
    })
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['✓ S2 slots[].component → 包入口/canonical barrel 公开导出'] })
    // 正向锚点：小写的 `fxLowercaseOnly` 被 /^[A-Z]/ 过滤掉 ⇒ 分母仍是 3 不是 4
    expect(run.stdout).toContain('3 个可消费组件名')
  })

  it('`component: null` 是合法的纯布局占位 —— 整脚本下也必须放行', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe({ slots: [fillerSlot(), fillerSlot({ slot: 'filler2' })] })],
    })
    const run = runGate(root, GATE)

    expectGateGreen(run, { contains: ['✓ S2 slots[].component'] })
    // 正向锚点：两个 slot 都进了 S5 的分母 ⇒ 闸确实在这棵 fixture 上跑过
    expect(run.stdout).toContain('S5 覆盖：2 个 slot 全查条件式散文；其中 0 个声明了 responsive 断点')
  })

  it('自印摘要的 recipe ids / slot 数 / responsive 数全是 fixture 自己的', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe(), recipe({ id: 'fx-beta-page', slots: [mainSlot({ slot: 'solo' })] })],
    })
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: [
        '2 recipe(s) checked: fx-alpha-page, fx-beta-page',
        'S5 覆盖：3 个 slot 全查条件式散文；其中 2 个声明了 responsive 断点',
      ],
    })
    expectNoRealRepoLeak(run.stdout)
  })
})

describe('audit-page-recipes CLI — S4 三态（fail-open 出口，跑到哪一支由环境决定）', () => {
  it('`dist/style.css` 不在 ⇒ **exit 0**，但三行 SKIPPED 警告全印、且不印 `✓ S4`', () => {
    // ⚠️ 这一支在**主仓 build 过之后结构上不可达**（dist 在 ⇒ 走真判）；
    //    而在 worktree 里它是默认走的那一支。两棵树跑同一条闸得到不同分支。
    const root = fixture() // 不写 shipped
    const run = runGate(root, GATE)

    expect(run.status).toBe(0)
    expect(run.stdout).toContain(`⚠ S4 layoutTokens + responsive.stacksBelow → ${SHIPPED}（消费方真拿到的那份） —— SKIPPED：${SHIPPED} 不在（gitignored 构建产物）。`)
    expect(run.stdout).toContain('先 `pnpm build` 才能判「源码里定义了是否真进 dist」')
    expect(run.stdout).toContain('⚠️ 这一层 SKIP 掉时，本闸**不**保证配方引用的 token 随包发 —— 别按「S4 绿」理解。')
    // must-not-hit：SKIPPED 时绝不能同时印出 S4 的绿行（那会让读的人以为 S4 判过了）
    expect(run.stdout).not.toContain(`✓ S4 layoutTokens`)
    // 正向锚点：其余四条照常判、照常印绿
    expect(run.stdout).toContain('✓ S5 响应式改位机读')
  })

  it('SKIPPED 时摘要行印「dist 未构建（S4 skipped）」而不是一个 token 数', () => {
    const root = fixture()
    const run = runGate(root, GATE)

    expectGateGreen(run, {
      contains: ['3 个可消费组件名 · 3 个源码 token 声明 · dist 未构建（S4 skipped）'],
    })
    expect(run.stdout).not.toContain('个 dist token 声明')
  })

  it('`layoutTokens` 的 token 在源码里有、dist 里没有 ⇒ **S3 绿而 S4 红**（ship-token-css 形态）', () => {
    const root = fixture({ shipped: SHIPPED_CSS_NO_GAP })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: `✗ S4 layoutTokens + responsive.stacksBelow → ${SHIPPED}`,
      checks: [
        `recipe "fx-alpha-page" layoutTokens.gap: token "--fx-gap" 不在 ${SHIPPED} 里`,
        // S3 照样绿 ⇒ 这一层确实是独立的第二道分母，不是 S3 的复述
        `✓ S3 layoutTokens → ${VARIABLES}`,
      ],
    })
  })

  it('`responsive.stacksBelow` 的断点在源码里有、dist 里没有 ⇒ S4 红（S4 覆盖的第二类 token）', () => {
    const root = fixture({ shipped: SHIPPED_CSS_NO_BP })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: `✗ S4 layoutTokens + responsive.stacksBelow → ${SHIPPED}`,
      checks: [
        `recipe "fx-alpha-page" slot "main": responsive.stacksBelow "--bp-fx-narrow" 不在 ${SHIPPED} 里`,
        '✓ S5 响应式改位机读', // S5 绿 ⇒ 源码侧定义齐全，红的只有「随包发」这一层
      ],
    })
  })
})

describe('audit-page-recipes CLI — 红档（接线走到 exit 1）+ failed 累加', () => {
  it('S1 拼错字段名 ⇒ 红，且 S2–S5 的绿行照印（`failed` 是累加、不是 early return）', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      // 结构其余部分完整 ⇒ 后四条判据仍能正常跑完
      recipes: [recipe({ notesTypo: 'schema 不认识这个键' })],
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S1 schema 结构',
      checks: ['must NOT have additional properties', 'notesTypo'],
    })
    expect(run.stdout).toContain('✓ S2 slots[].component')
    expect(run.stdout).toContain(`✓ S3 layoutTokens → ${VARIABLES}`)
    expect(run.stdout).toContain(`✓ S4 layoutTokens`)
    expect(run.stdout).toContain('✓ S5 响应式改位机读')
  })

  it('S2 引用了两个 barrel 都没导出的名字 ⇒ 红且点名 recipe + slot', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe({ slots: [mainSlot({ component: 'FxUnexportedHost' })] })],
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S2 slots[].component → 包入口/canonical barrel 公开导出',
      checks: [
        'recipe "fx-alpha-page" slot "main": component "FxUnexportedHost" 不是可消费的组件名',
        '想表达「纯布局占位、内容由 app 决定」请写 component: null',
      ],
    })
  })

  it('S3 引用了 variables.css 里没有的 token ⇒ 红', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe({ layoutTokens: { gap: 'var(--fx-never-defined)' } })],
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: `✗ S3 layoutTokens → ${VARIABLES}`,
      checks: [`recipe "fx-alpha-page" layoutTokens.gap: token "--fx-never-defined" 在 ${VARIABLES} 里没有定义`],
    })
  })

  it('S5 (a) `position` 写成条件式散文 ⇒ 红并指向 responsive 字段', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe({ slots: [mainSlot({ position: 'left (or top on narrow screens)' })] })],
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S5 响应式改位机读',
      checks: [
        'position "left (or top on narrow screens)" 写成了条件式散文',
        '说了会变、没说在哪变，AI 解析不出切换点',
      ],
    })
  })

  it('S5 (b) `stacksBelow` 定义过、但不属 `--bp-*` 组 ⇒ 红（断点只能引断点）', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [
        recipe({
          slots: [
            mainSlot({
              responsive: { stacksBelow: '--fx-not-a-breakpoint', stackedPosition: 'top' },
            }),
          ],
        }),
      ],
    })
    const run = runGate(root, GATE)

    // ⚠️ schema 的 `^--bp-` pattern 先开火（S1），S5 的「定义过但不是断点」在同一趟里也开火
    // ⇒ 两条判据一起红，正是「pattern 只管形状」与「真属那一组」两半都不能省的实证。
    expectGateRed(run, {
      marker: '✗ S5 响应式改位机读',
      checks: [
        'responsive.stacksBelow "--fx-not-a-breakpoint" 定义过，但不属 --bp-* 断点组',
        '✗ S1 schema 结构',
      ],
    })
  })

  it('S2 / S3 / S4 / S5 同时红 ⇒ 四条判据全部点名，退出码仍是 1（不是撞上第一条就走）', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [
        recipe({
          slots: [mainSlot({ component: 'FxUnexportedHost', position: 'left when narrow' })],
          layoutTokens: { gap: 'var(--fx-never-defined)' },
        }),
      ],
    })
    const run = runGate(root, GATE)

    expectGateRed(run, {
      marker: '✗ S2 slots[].component → 包入口/canonical barrel 公开导出',
      checks: [
        `✗ S3 layoutTokens → ${VARIABLES}`,
        `✗ S4 layoutTokens + responsive.stacksBelow → ${SHIPPED}`,
        '✗ S5 响应式改位机读',
        'component "FxUnexportedHost" 不是可消费的组件名',
        'token "--fx-never-defined" 在',
        'position "left when narrow" 写成了条件式散文',
      ],
    })
    // ⚠️ S4 跟着 S3 一起红是**设计接受的重复报**，闸的报错文本自己逐字写了这一点。
    //    （初版这里断言的是「S4 仍绿」—— 那是断言写错、不是闸的问题：源码里没定义的
    //     token，dist 里当然也没有。⇒ 顺手把这条真实行为钉住。）
    expect(run.stderr).toContain('源码里若也没定义，S3 会同时点名')
    // S1 仍绿 ⇒ 红的确实只有那四条，不是「一坏全坏」
    expect(run.stdout).toContain('✓ S1 schema 结构')
  })

  it('红档的**终态事实**：stderr 最后一行是闸自己的真源提示末句，不是崩溃栈尾', () => {
    const root = fixture({
      shipped: SHIPPED_CSS_BOTH,
      recipes: [recipe({ layoutTokens: { gap: 'var(--fx-never-defined)' } })],
    })
    const run = runGate(root, GATE)

    expect(run.status).toBe(1)
    expect(lastLine(run.stderr)).toBe(RED_LAST_LINE_TAIL)
  })
})

describe('audit-page-recipes CLI — 输入路径全随 fixture 走（无 fallback 回真仓库的凭据）', () => {
  // ⚠️ 这三条钉的是**崩溃**不是判据红：闸对缺输入没有 fail-closed 出口，`readFileSync`
  //    直接抛。⛔ 所以刻意**不用** `expectGateRed` —— 那会把崩溃当成「判据开火了」
  //    （harness 判据 2 逐字警告过的「假红」）。断言取的是 ENOENT 的**路径指向哪棵树**。

  it(`缺 ${RECIPES} ⇒ ENOENT 且路径指向 fixture，⛔ 不退回真仓库、⛔ 不当「读不到就通过」`, () => {
    const root = fixture({ omit: 'recipes', shipped: SHIPPED_CSS_BOTH })
    const run = runGate(root, GATE)

    expect(run.status).not.toBe(0)
    expect(run.stderr).toContain('ENOENT')
    expect(run.stderr).toContain(`${root}/${RECIPES}`)
    expect(run.stdout).toBe('') // 一条判据都没来得及印 ⇒ 确实死在第一次读取上
    expectNoRealRepoLeak(`${run.stdout}${run.stderr}`)
  })

  it(`缺 ${SCHEMA} ⇒ ENOENT 且路径指向 fixture（结构真源同样不从真仓库兜底）`, () => {
    const root = fixture({ omit: 'schema', shipped: SHIPPED_CSS_BOTH })
    const run = runGate(root, GATE)

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

  it(`缺 ${VARIABLES} ⇒ ENOENT 且路径指向 fixture（S3/S5 的分母也不从真仓库兜底）`, () => {
    const root = fixture({ omit: 'variables', shipped: SHIPPED_CSS_BOTH })
    const run = runGate(root, GATE)

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