// tests/audit-token-contract.test.ts
// -----------------------------------------------------------------------------
// `audit:token-contract`（L4 pre-commit 条件触发 + L5 gate-chain）的**整脚本**回归面。
//
// 为什么是整脚本而不是 import 判据函数：该闸零导出，判据写在模块顶层（no `main()`），
// 量具 `pnpm report:gate-regression-face` 此前把它记在「零判据覆盖」。
// ⛔ 闸本体一行没改（凭据 = `shasum -a 256` 注入前后比对 + `git status scripts/` 空）。
//
// 挂载真源（脚本头注释是挂载真源，此处只登记本文件测到哪一层）：
//   L4 = `.husky/pre-commit`，**条件触发** —— 仅当 staged 命中
//        `src/tokens/variables.css` / `figma-data/normalized/variables.json` / 闸本体 时才跑。
//   L5 = `package.json` 的 `gate-chain`（由 `prepublishOnly` → `scripts/run-gate-chain.mjs` 跑）。
//   ⛔ 本文件**不**声明挂载层真的挂上了 —— 那是 `audit-gate-ci-parity` +
//      `audit-gate-mount-declaration` 的判据面（[[INFRA-F138]] 诚实边界 ①）。
//      （这两个名字刻意**不带** `.mjs`、也不带 `audit:` 冒号 —— 见下方 🔴 脱敏那段。）
//
// ✅ 绿档**能**自证非空过（与 `tests/audit-doc-de-mirror.test.ts` 那条相反）：闸自印
//   `（N primitives · M declarations across dark+light）`，两个数都是**从 fixture 数据算出来的**
//   （`primitives.size` / `renderTokens` 的 `refreshed`）。真仓库现值 54 / 108 ⇒ fixture 用
//   3 / 6，撞不上。⇒ 直接钉这两个数即可，不必退化成红绿配对。
//
// 🔴 **本文件登记一条边界（会咬人，先读再改）**：绿档 fixture 的 CSS **必须以
//   `figma-sync/generate-tokens.mjs` 里那份 `HEADER` 逐字开头**，否则 `renderTokens` 拼出的
//   `css` 与 `current` 必然不等、闸恒红。⇒ 下方 `HEADER` 是那份常量的**逐字副本**。
//   改了活源那份 HEADER ⇒ 本文件的绿档用例会红。**那是设计意图，不是脆弱**：它同时是
//   HEADER 的漂移钉（同 `slugifySectionTitle` 与 F88 视觉闸内联副本的既有范式）。
//   修法 = 照红档印出的 `expected:` 行把下方副本同步过来，⛔ 别改闸、⛔ 别把绿档降级成只断言 exit 0。
//
// 🔴 **脱敏（本文件当场抓到并修掉的一个读数污染，形态是上半场纪律没覆盖到的第三种）**：
//   [[INFRA-F138]] 已记两种「别写真实闸名」的场合 —— fixture 假名、散文提闸去后缀。
//   本文件撞出**第三种：断言字符串本身**。被测闸的绿档 stdout 里逐字印着另一条闸的 npm key
//   （`pnpm audit:` + `variables-freshness` —— 它是这条闸「不能证」那半句的指路），于是
//   「断言绿档印出了那句话」就必然要在测试里写出那个 key。实测代价：量具把
//   `audit-figma-variables-freshness`（基线在**零判据覆盖**清单里）从 `NONE` 推成了 `M`。
//   ⇒ 方向是**低报侧**（`NONE` 与 `M` 同属「零判据覆盖」类组，类组总数仍正确地只减 1，
//   不像上半场那次把零覆盖伪装成已覆盖），但仍是假读数。**修法 = 断言截短，去掉 `audit:` 冒号**
//   （见下方那条用例里的注释）。⛔ 别为了脱敏改闸的 stdout。
//   发现它靠的是**逐格 diff**：清单行里只看到 1 条移动，而分类格是 `E-fx +1 · M +1 · NONE −2`
//   —— 差额藏在「清单不打 NONE/M 标签」这个显示口径里，红绿与退出码全无信号。
//
// 输入面闭合（`linkDirs: []` 是一条断言，不是省事）：该闸的全部输入 = 下面两份 fixture 文件
//   + `copyFiles` 拷进来的 `figma-sync/generate-tokens.mjs`。它不碰 `scripts/lib`，
//   所以显式关掉默认软链 —— 若哪天它开始 import lib，fixture 会当场 ENOENT 而不是静默走真仓库。
//   ⛔ 那份 import 的同仓脚本必须**拷**不能软链：ESM 走 realpath，软链会把它的 root 解回真仓库
//   （理由全文在 `tests/lib/gate-fixture-root.ts` 的 `copyFiles` 注释）。
//
// 覆盖：mode key 推断（含 fallback / fail-closed）· `cssVar` 与 null 值过滤 · scope 追踪
//       （`:root` 变体 / `[data-theme="light"]` / 块外不改）· authored token 逐字保留（按名不按值）
//       · header 替换与 section marker 缺失 · 逐行 diff 诊断（含 40 行截断）· 输入缺失 fail-closed
//       · 退出码与流向（stdout/stderr）。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterEach } from 'vitest'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-token-contract.mjs'
const GENERATOR = 'figma-sync/generate-tokens.mjs'
// 🔴 2026-09-01 下半场新增的传递依赖 —— `generate-tokens.mjs` 开始 import `cssUnitFor`
//    去给 FLOAT 补 CSS 单位。⚠️ 这**印证了**文件头那条「若哪天它开始 import lib，fixture
//    会当场 ENOENT 而不是静默走真仓库」—— 真的发生了，而且 fixture 确实当场 ENOENT（34 个
//    用例一起红、错误是 ERR_MODULE_NOT_FOUND），没有一个悄悄走了真仓库的表。
//    ⛔ 三个都必须**拷**不能软链（ESM 走 realpath，软链会把 root 解回真仓库）。
const VARIABLE_MAP = 'figma-sync/variable-map.mjs'
const VARIABLES_PAYLOAD_LIB = 'figma-sync/lib/variables-payload.mjs'
const IS_CLI_ENTRY = 'scripts/lib/is-cli-entry.mjs'
const CSS = 'src/tokens/variables.css'
const JSON_PATH = 'figma-data/normalized/variables.json'

/**
 * `figma-sync/generate-tokens.mjs` 里 `buildHeader()` 的**逐字副本**（fixture 有 3 个 primitive）。
 * ⛔ 别顺手编辑 —— 见文件头那条 🔴 边界（它是漂移钉，改活源才改这里）。
 *
 * 🔴 **2026-09-01 下半场：活源那边 `HEADER` 常量变成了 `buildHeader(primitiveCount)` 函数** ——
 *    因为原文写死的 "all 54 Figma Variables" 在上游变成 98、已生成 CSS 变成 75 的当天就失准，
 *    而它只是注释、没有任何闸会发现。⇒ 现在条数由**当次真实的 `primitives.size`** 填进去，
 *    fixture 这份副本相应写 `3`（本文件 `baseVariables()` 的条数），并由下方那条
 *    「header 逐字」用例钉住 —— 活源改一个字，这里就红。
 */
const HEADER = `/* AUTO-GENERATED — Figma-synced tokens refreshed; code-authored tokens PRESERVED.
 *
 * SYNCED FROM FIGMA by \`pnpm generate\` (source figma-data/normalized/variables.json) —
 *   3 Figma Variables, matched BY NAME. Two kinds:
 *     · COLOR — NOT just the raw palette (--color-grey-*, --brand, --red, --orange, --blue):
 *       it INCLUDES the semantic color layer (Figma "Color Type/*" → --text-*, --icon-*,
 *       --bg-*, --line-*). Declared per theme (:root = Dark, [data-theme="light"] = Light).
 *     · FLOAT — sizing scales (Figma "Spacing/*" → --sp-*, "Radius/*" → --r-*, "Size/*" →
 *       --size-*, "Module Width/*" → --module-w-*). Theme-independent, so they are declared
 *       ONCE in :root. Figma stores them as bare numbers; the CSS unit comes from
 *       FLOAT_CSS_UNIT in figma-sync/variable-map.mjs (named per token — a FLOAT with no
 *       entry there is a hard error, never a silent \`4\`).
 *       NOTE: --module-w-* (module/card widths, 200–1200) is NOT the same family as the
 *       code-authored --container-* page shell widths (720/840/1600/1800). Zero overlap.
 *   Gated by \`pnpm audit:token-contract\`. Do NOT hand-edit their values — edit Figma +
 *   re-sync. (The semantic color and spacing ones ARE Figma-sourced even though they look
 *   "authored"; hand-editing them fails the gate.)
 * CODE-AUTHORED (no Figma Variable; PRESERVED verbatim) — control sizing (--control-height-*),
 *   effect/text-style composites, component-variant (--notification-* / --progress-* / --slider-*),
 *   layout breakpoints/containers (owner-ruled code-first), derived aliases. NOT gated.
 *
 * Dark/Light theme: apply [data-theme="light"] to <html> to switch to light mode.
 */`

type Variable = Record<string, unknown>

/** 3 个 primitive，mode key 用真仓库同名的 `Dark` / `Light`。 */
function baseVariables(): Variable[] {
  return [
    { id: 'VariableID:1:1', figmaName: 'FX/Alpha', Dark: '#111111', Light: '#eeeeee', cssVar: '--fx-alpha' },
    { id: 'VariableID:1:2', figmaName: 'FX/Beta', Dark: '#222222', Light: '#dddddd', cssVar: '--fx-beta' },
    { id: 'VariableID:1:3', figmaName: 'FX/Gamma', Dark: '#333333', Light: '#cccccc', cssVar: '--fx-gamma' },
  ]
}

const asJson = (variables: Variable[]) => `${JSON.stringify({ variables }, null, 2)}\n`

/**
 * 与 `baseVariables()` 完全同步的 CSS —— 绿档基线。
 *
 * `--fx-authored: #111111` 是一条**按名不按值**的钉：它的值逐字等于 `--fx-alpha` 的 dark 值，
 * 但名字不在 primitives 里 ⇒ 必须被逐字保留。若闸改成按值匹配，light 块里那个同名条目会被
 * 改写成 light 值 ⇒ 当场红。
 */
function baseCss(): string {
  return `${HEADER}

/* ── FX primitives ── */
:root {
  --fx-alpha: #111111;
  --fx-beta: #222222;
  --fx-gamma: #333333;
  --fx-authored: #111111; /* authored — no Figma Variable, preserved verbatim */
}

[data-theme="light"] {
  --fx-alpha: #eeeeee;
  --fx-beta: #dddddd;
  --fx-gamma: #cccccc;
  --fx-authored: #111111; /* authored — no Figma Variable, preserved verbatim */
}
`
}

function build(
  opts: { css?: string; variables?: Variable[]; drop?: Array<typeof CSS | typeof JSON_PATH> } = {},
): string {
  const files: Record<string, string> = {
    [CSS]: opts.css ?? baseCss(),
    [JSON_PATH]: asJson(opts.variables ?? baseVariables()),
  }
  for (const k of opts.drop ?? []) delete files[k]
  return createGateFixture({
    gate: GATE,
    prefix: 'token-contract-fx',
    files,
    copyFiles: [GENERATOR, VARIABLE_MAP, VARIABLES_PAYLOAD_LIB, IS_CLI_ENTRY],
    linkDirs: [], // 输入面闭合 —— 见文件头
  })
}

const PASS = '✓ token-contract PASS'
const FAIL = '❌ token-contract FAIL: variables.css is OUT OF SYNC'

afterEach(cleanupGateFixtures)

describe('audit:token-contract — 绿档（自印 fixture 自己的读数 = 非空过凭据）', () => {
  it('完全同步 ⇒ exit 0，且自印的两个数都是 fixture 算出来的（3 / 6，真仓库是 54 / 108）', () => {
    const run = runGate(build(), GATE)
    expectGateGreen(run, {
      contains: [PASS, '（3 primitives · 6 declarations across dark+light）'],
    })
    // 反向钉：真仓库的读数绝不能出现 —— 出现就说明闸退回真仓库读了（P1=FAIL(cwd) 形态的无-fallback 凭据）
    expect(run.stdout).not.toContain('54 primitives')
    expect(run.stdout).not.toContain('108 declarations')
  })

  it('绿档印出那两行**认识论边界**（闸刻意的产出物，头注释 ⛔ 禁止把射程改回去）', () => {
    const run = runGate(build(), GATE)
    expectGateGreen(run, {
      contains: [
        '能证：没有人手改过 CSS 侧的同步值',
        '不能证：那份 normalized 与 Figma 端**此刻**的变量值一致',
        // ⛔ 刻意写成不带 `audit:` 冒号的截断形态 —— 带上就会把那条**零覆盖**闸
        //    在量具里从 NONE 污染成 M（理由与实测见文件头 🔴 脱敏段）。
        //    这个片段在该闸 stdout 里唯一，截短不损断言强度。
        'variables-freshness',
      ],
    })
  })

  it('`refreshed` 数的是**声明数**不是 token 数 —— 少一个 light 声明 ⇒ 印 5 而不是 6', () => {
    const css = baseCss().replace('  --fx-gamma: #cccccc;\n', '')
    const run = runGate(build({ css }), GATE)
    expectGateGreen(run, { contains: ['（3 primitives · 5 declarations across dark+light）'] })
  })
})

describe('audit:token-contract — loadPrimitives 的 mode key 推断与过滤', () => {
  it('mode key 靠**名字含 dark/light**认（大小写不敏感）—— `theme-DARK` / `theme-Light` 一样认得', () => {
    const variables = baseVariables().map(v => {
      const { Dark, Light, ...rest } = v as Record<string, unknown>
      return { ...rest, 'theme-DARK': Dark, 'theme-Light': Light }
    })
    const run = runGate(build({ variables }), GATE)
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })

  it('两个 key 都不含 dark/light ⇒ fallback 到出现顺序（[0]=dark / [1]=light），不是报错', () => {
    const variables = baseVariables().map(v => {
      const { Dark, Light, ...rest } = v as Record<string, unknown>
      return { ...rest, ModeA: Dark, ModeB: Light } // ModeA 先出现 ⇒ 当 dark
    })
    const run = runGate(build({ variables }), GATE)
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })

  it('fallback 的顺序**真的**是位置序 —— 把 ModeB 放前面 ⇒ 值反过来贴 ⇒ 红（阴性对照，证明上一条不是空过）', () => {
    const variables = baseVariables().map(v => {
      const { Dark, Light, ...rest } = v as Record<string, unknown>
      return { ...rest, ModeB: Light, ModeA: Dark } // light 值先出现 ⇒ 被当 dark
    })
    const run = runGate(build({ variables }), GATE)
    expectGateRed(run, { marker: FAIL, checks: ['--fx-alpha: #eeeeee', '--fx-alpha: #111111'] })
  })

  it('只有一个 mode key ⇒ **fail-closed 抛错点名**，不是静默当零个 primitive 放行', () => {
    const variables = baseVariables().map(v => {
      const { Dark, Light, ...rest } = v as Record<string, unknown>
      return { ...rest, OnlyOne: Dark }
    })
    const run = runGate(build({ variables }), GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('Could not determine Dark/Light mode keys')
    expect(run.stdout).not.toContain(PASS)
  })

  it('mode key 从**第一个带 cssVar 的**条目推 —— 前面塞个无 cssVar 的杂项不影响推断', () => {
    const variables: Variable[] = [
      { id: 'VariableID:0:0', figmaName: 'FX/NoCssVar', Dark: '#000000', Light: '#ffffff' },
      ...baseVariables(),
    ]
    const run = runGate(build({ variables }), GATE)
    // 那条无 cssVar 的被过滤 ⇒ size 仍是 3
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })

  it('某个 mode 值为 `null` ⇒ 该条**整条**被过滤，CSS 里同名 token 退化成 authored 逐字保留', () => {
    const variables: Variable[] = [
      ...baseVariables(),
      { id: 'VariableID:1:4', figmaName: 'FX/Delta', Dark: null, Light: '#bbbbbb', cssVar: '--fx-delta' },
    ]
    // 故意让 --fx-delta 在两个块里值**不同**：若它被当 primitive，dark 侧会被写成 `null` ⇒ 红
    const css = baseCss()
      .replace('  --fx-authored: #111111; /* authored', '  --fx-delta: #444444;\n  --fx-authored: #111111; /* authored')
      .replace('  --fx-gamma: #cccccc;', '  --fx-gamma: #cccccc;\n  --fx-delta: #999999;')
    const run = runGate(build({ variables, css }), GATE)
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
    expect(run.stdout).not.toContain('4 primitives')
  })
})

describe('audit:token-contract — renderTokens 的 scope 追踪与 authored 保留', () => {
  it('`:root` 的变体选择器也算 dark（`startsWith(":root")`）—— `:root:not([data-theme="light"])` 走 dark 值', () => {
    const css = baseCss().replace(':root {', ':root:not([data-theme="light"]) {')
    const run = runGate(build({ css }), GATE)
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })

  it('**块外**（`}` 之后、下个选择器之前）的 primitive 行不被改写 —— scope 会在 `}` 处重置', () => {
    const css = baseCss().replace(
      '\n[data-theme="light"] {',
      '\n/* 块外的同名声明：scope=null ⇒ 逐字保留，不该被贴上 dark 值 */\n--fx-alpha: #999999;\n\n[data-theme="light"] {',
    )
    const run = runGate(build({ css }), GATE)
    // 仍是 6 —— 块外那行没被计入 refreshed
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })

  it('authored token 按**名**判、不按值判 —— 值撞上某 primitive 也照样逐字保留（含行尾注释）', () => {
    // baseCss 里 --fx-authored 的值就是 --fx-alpha 的 dark 值；两个块里都写同一个值。
    // 若改成按值匹配，light 块那行会被改写 ⇒ 红。绿即证明按名。
    const run = runGate(build(), GATE)
    expectGateGreen(run, { contains: [PASS] })
  })

  it('authored token 的行尾注释被逐字保留 —— 改一个字符就红，并把两侧原文都印出来', () => {
    const css = baseCss().replace(
      '  --fx-beta: #222222;',
      '  --fx-beta: #222222; /* trailing comment must survive */',
    )
    const run = runGate(build({ css }), GATE)
    // --fx-beta 是 primitive：trailing 被捕获组保留 ⇒ 重建后与原文一致 ⇒ 仍绿
    expectGateGreen(run, { contains: ['（3 primitives · 6 declarations across dark+light）'] })
  })
})

describe('audit:token-contract — 红档：drift 点名与逐行诊断', () => {
  it('手改 dark 值 ⇒ exit 1 + 点名判据 + 印出 file/expected 两侧原文', () => {
    const css = baseCss().replace('  --fx-alpha: #111111;\n  --fx-beta', '  --fx-alpha: #dead00;\n  --fx-beta')
    const run = runGate(build({ css }), GATE)
    expectGateRed(run, {
      marker: FAIL,
      checks: ['1 differing line(s):', 'file:     --fx-alpha: #dead00;', 'expected: --fx-alpha: #111111;'],
    })
  })

  it('手改 light 值 ⇒ 同样被抓（证明 light 块不是只被 scope 认出、值也真的比了）', () => {
    const css = baseCss().replace('  --fx-gamma: #cccccc;', '  --fx-gamma: #beef00;')
    const run = runGate(build({ css }), GATE)
    expectGateRed(run, {
      marker: FAIL,
      checks: ['1 differing line(s):', 'file:     --fx-gamma: #beef00;', 'expected: --fx-gamma: #cccccc;'],
    })
  })

  it('改 CSS 侧的 header ⇒ 红（header 是判据的一部分：闸断言「生成的 header 是当前的」）', () => {
    const css = baseCss().replace('AUTO-GENERATED', 'HAND-EDITED')
    const run = runGate(build({ css }), GATE)
    expectGateRed(run, { marker: FAIL, checks: ['differing line(s):', 'HAND-EDITED'] })
  })

  it('CSS 缺 `/* ── ` section marker ⇒ 整份被当 body、header 被前置 ⇒ 红（fail-closed，不是静默放行）', () => {
    const css = baseCss().replace('/* ── FX primitives ── */\n', '')
    const run = runGate(build({ css }), GATE)
    expectGateRed(run, { marker: FAIL, checks: ['differing line(s):'] })
  })

  it('drift 超过 40 行 ⇒ 只印前 40 条 + 尾行汇总剩余数（41 个 primitive 全错 ⇒ 「…and 1 more.」）', () => {
    const variables: Variable[] = Array.from({ length: 41 }, (_, i) => ({
      id: `VariableID:9:${i}`,
      figmaName: `FX/Bulk${i}`,
      Dark: `#a${String(i).padStart(5, '0')}`,
      Light: `#b${String(i).padStart(5, '0')}`,
      cssVar: `--fx-bulk-${i}`,
    }))
    // CSS 只有 dark 块，且 41 行值全错 ⇒ drifts = 41
    // ⚠️ header 里的条数必须写 41（= 本用例的 primitive 数），否则 header 那一行**自己也算一处
    //    drift**，本用例就从「41 + 截断」变成「42 + 截断」，测的东西就混了两件事。
    //    （2026-09-01 活源把写死的 54 改成 `buildHeader(primitives.size)` 后，这里当场红过一次。）
    const rows = variables.map((_, i) => `  --fx-bulk-${i}: #000000;`).join('\n')
    const css = `${HEADER.replace('*   3 Figma Variables', '*   41 Figma Variables')}\n\n/* ── FX bulk ── */\n:root {\n${rows}\n}\n`
    const run = runGate(build({ variables, css }), GATE)
    expectGateRed(run, { marker: FAIL, checks: ['41 differing line(s):', '…and 1 more.'] })
    // 截断真的发生了：第 41 行（index 40）的 expected 不该被逐行印出
    expect(run.stderr).not.toContain('--fx-bulk-40: #a00040')
  })

  it('header 里的条数是**当次 primitive 数**、不是写死的 —— 条数对不上即 drift（2026-09-01 新行为）', () => {
    // 活源此前写死 "all 54 Figma Variables"，上游变 98 / 已映射变 75 的当天就失准，
    // 而它只是注释 ⇒ 没有任何闸会发现。改成 `buildHeader(primitives.size)` 之后，
    // 「注释里的条数」进入了 `css === current` 的比较面，本用例就是那道锁。
    const css = baseCss().replace('*   3 Figma Variables', '*   4 Figma Variables')
    const run = runGate(build({ css }), GATE)
    expectGateRed(run, { marker: FAIL, checks: ['3 Figma Variables', '4 Figma Variables'] })
  })

  it('FLOAT（裸数字）按 FLOAT_CSS_UNIT 补单位；表里没有的 FLOAT ⇒ **抛错点名**，不是静默生成无单位值', () => {
    // ⚠️ 这两条**刻意用真表里的真 token 名**（`--sp-xxs` 在 FLOAT_CSS_UNIT 里、`--fx-nounit` 不在）——
    //    单位表是活源的一部分，fixture 造个假表就测不到「接线」这一半（memory:
    //    gate-regression-face-whole-script-fixture）。
    const withUnit: Variable[] = [
      { id: 'VariableID:2:1', figmaName: 'Spacing/XXS', Dark: 4, Light: 4, cssVar: '--sp-xxs' },
    ]
    const okCss = `${HEADER.replace('*   3 Figma Variables', '*   1 Figma Variables')}\n\n/* ── FX ── */\n:root {\n  --sp-xxs: 4px;\n}\n`
    expectGateGreen(runGate(build({ variables: withUnit, css: okCss }), GATE))

    // 阴性对照：同一条链，只把 cssVar 换成单位表里没有的名字 ⇒ 必须炸，且点名那个 token
    const noUnit: Variable[] = [
      { id: 'VariableID:2:2', figmaName: 'FX/NoUnit', Dark: 4, Light: 4, cssVar: '--fx-nounit' },
    ]
    const badCss = `${HEADER.replace('*   3 Figma Variables', '*   1 Figma Variables')}\n\n/* ── FX ── */\n:root {\n  --fx-nounit: 4px;\n}\n`
    const run = runGate(build({ variables: noUnit, css: badCss }), GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('--fx-nounit')
    expect(`${run.stderr}${run.stdout}`).toContain('FLOAT_CSS_UNIT')
  })

  it('红档走 **stderr**、且 stdout 里没有 PASS 串（流向钉：CI 抓 stdout 的话会漏判）', () => {
    const css = baseCss().replace('  --fx-beta: #222222;', '  --fx-beta: #000000;')
    const run = runGate(build({ css }), GATE)
    expect(run.status).toBe(1)
    expect(run.stderr).toContain(FAIL)
    expect(run.stdout).not.toContain(PASS)
    expect(run.stderr).toContain('Run `pnpm generate` to re-sync primitives')
  })
})

describe('audit:token-contract — 输入缺失 fail-closed（含「无 fallback 回真仓库」凭据）', () => {
  it('`figma-data/normalized/variables.json` 不存在 ⇒ 非零退出，且 ENOENT 路径指向 **fixture**', () => {
    const root = build({ drop: [JSON_PATH] })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(run.stderr).toContain('ENOENT')
    expect(run.stderr).toContain(JSON_PATH)
    // 三向实证的第 ③ 向：路径落在 fixture root 内 ⇒ 闸没有偷偷读回真仓库
    expect(run.stderr).toContain(root)
    expect(run.stdout).not.toContain(PASS)
  })

  it('`src/tokens/variables.css` 不存在 ⇒ 非零退出，且 ENOENT 路径指向 **fixture**', () => {
    const root = build({ drop: [CSS] })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(run.stderr).toContain('ENOENT')
    expect(run.stderr).toContain(CSS)
    expect(run.stderr).toContain(root)
    expect(run.stdout).not.toContain(PASS)
  })

  it('`variables.json` 是畸形 JSON ⇒ 非零退出（不是当空 primitives 集静默放行）', () => {
    const root = createGateFixture({
      gate: GATE,
      prefix: 'token-contract-fx',
      files: { [CSS]: baseCss(), [JSON_PATH]: '{ "variables": [ }\n' },
      copyFiles: [GENERATOR, VARIABLE_MAP, VARIABLES_PAYLOAD_LIB, IS_CLI_ENTRY],
      linkDirs: [],
    })
    const run = runGate(root, GATE)
    expect(run.status).not.toBe(0)
    expect(run.stdout).not.toContain(PASS)
  })

  it('`variables` 数组为空 ⇒ **抛错**（mode key 推不出），⛔ 不是「0 primitives 一致」的假绿', () => {
    const run = runGate(build({ variables: [] }), GATE)
    expect(run.status).not.toBe(0)
    expect(`${run.stderr}${run.stdout}`).toContain('Could not determine Dark/Light mode keys')
    expect(run.stdout).not.toContain(PASS)
  })
})
