// tests/lib/gate-fixture-root.ts
// -----------------------------------------------------------------------------
// 共享 fixture-root harness —— 给**活闸**补整脚本回归面，零改动被测对象。
//
// WHY（[[INFRA-F138]]）：本仓 2026-08-24 实测「这条闸有单测」≠「这条闸的判据被守住」，
//   两者的差正好落在最贵的那一半上 —— **接线**。实证（`audit:doc-shape`，L4+L5 阻塞闸、
//   20 条单测）：摘掉 `main()` 的接线后，闸对真违例改印 `✓ PASS` 且 exit 0，而全量 vitest
//   1993 passed / EXIT=0。⇒ 只 import 判据函数的测试，结构上看不见接线。
//
// 判据：**spawn 整个脚本**，不 import 它的符号。被测对象一行不改。
//   ⛔ 别为了让本 harness 能用去改活闸取 root 的方式（[[INFRA-F138]] 逐字封的路）。
//
//   🔴 **2026-08-26 订正：量具的 `P1` 列不是本 harness 的筛选条件。**
//   此前这里逐字写着「闸必须自己就是 `resolve(dirname(fileURLToPath(import.meta.url)), '..')`
//   取 root…P1 不是 PASS 的闸走不了这条路，那是**筛选条件**，不是待修项」—— 而
//   `runGate` 本来就传 `cwd: root`（见下方它的实现），于是**两条路都通**：
//     · `import.meta.url` 取 root 的闸 —— 被**拷**进 fixture ⇒ root 落在 fixture
//     · `process.cwd()`   取 root 的闸 —— spawn 时 cwd 即 fixture ⇒ 同样落在 fixture
//   实测三向（拿 `audit-figma-naming-hygiene` 这条 `ROOT = process.cwd()` 的闸做的）：
//   ① 正例读到 fixture 自己的读数 ② 产物落在 fixture 内 ③ 删掉输入 ⇒ ENOENT 且路径
//   指向 fixture（**无 fallback 回真仓库**）。
//   ⇒ 真正的筛选条件是「闸的**全部**输入路径是否都随 fixture 走」：混用
//   `import.meta.url` 算 root 的**同仓 import** 要靠 `copyFiles` 兜 —— 而那对 P1=PASS
//   的闸**同样**存在，不是 cwd 类特有的。
//   ⛔ 别把这条读成「P1 这一列没用」：它仍如实记录一个形态差别（拷过去就换 root ／
//   靠换 cwd 才换 root），只是**不构成封路**。兑现 = `tests/audit-doc-de-mirror.test.ts`
//   与 `tests/audit-export-coverage.test.ts`（两条都是 P1=FAIL(cwd)，零改动补上整脚本回归面）。
//
//   🔴 **2026-08-27：fixture root 必须 realpath，否则手写入口守卫的闸恒假、整个不执行。**
//   ⚠️ **机制不是本轮发现的**（⛔ 别把它写成新发现）：2026-08-26 第七 / 第八轮已两次撞到并
//   逐字记进 [[INFRA-F138]] entry 与 `scripts/lib/is-cli-entry.mjs` 头注释。本轮改的是
//   **它当时只被就地绕过、没进共享 harness** —— `tests/audit-product-code.test.ts:282` 与
//   `tests/audit-mockup-library-binding.test.ts:71` 各自手写了一次 `realpathSync`，而那两份
//   都是自建 fixture、不走本 harness ⇒ 走本 harness 的人会**再撞一次**。本文件存在的理由
//   逐字就是「不抽，第 2 条到第 N 条每条都要重付一遍」，这一条同样适用。
//
//   `mkdtempSync(tmpdir())` 在 macOS 返回 `/var/folders/…`，而 **`/var` 自己是 symlink**。
//   于是 `argv[1]`（调用时写下的路径，不解析 symlink）≠ `import.meta.url`（已 realpath），
//   凡是自己手写入口守卫的闸 —— 三种写法 `argv[1] === fileURLToPath(import.meta.url)` /
//   `import.meta.url === \`file://${argv[1]}\`` / `resolve(argv[1]) === resolve(…)` ——
//   **全判成「我是被 import 的」⇒ main() 整个不执行、exit 0、stdout 零字节**。
//   实测（`scripts/audit-consumer-contract.mjs`，2026-08-27）：不 realpath ⇒ `EXIT=0` 且
//   `STDOUT_LEN=0`；realpath 后 ⇒ 5 行自印 + `✅ PASS`。同一个病根 `scripts/lib/is-cli-entry.mjs`
//   的头注释已逐字记过，只是它的治理面（[[INFRA-F140]]）**只覆盖 16 条随包脚本**，
//   而本 harness 的候选池里 **14 条非随包闸仍用原始守卫**（2026-08-27 实测：21 条
//   「P2 干净 + P3 ∈ {NAMED,DIRLIST}」候选里 14 条命中，另 7 条是顶层即执行、无守卫）。
//   ⛔ 那 14 条**不是缺陷待修**：它们不随包、只从仓库根跑，原始守卫在那儿不会假。
//
//   ⚠️ 这同时是一处**跨平台不一致**：Linux CI 的 `/tmp` 通常不是 symlink ⇒ 同一份测试
//   在 CI 绿、在 macOS 红。既有的 harness 用户全是「无守卫 / `is-cli-entry`」两类，
//   所以从没撞上 —— **别把「没撞上」读成「不存在」**。
//
//   🔴 **2026-08-28：spawn 闸时必须剥掉继承的 `GIT_*` 环境变量，否则 fixture 隔离在
//   pre-commit 里是破的 —— 而且它不止让测试红，它会往真仓库的 index 里写东西。**
//   ⚠️ **机制不是本轮发现的**（⛔ 别写成新发现）：`tests/audit-deliverable-open-questions.test.ts:114`
//   逐字记过同一条，并就地建了个 `cleanEnv`。本轮改的和 `realpathSync` 那条**完全同构** ——
//   它当时**只被就地绕过、没进共享 harness** ⇒ 走本 harness 的下一个人（[[INFRA-F138]]
//   第六十五轮的 `audit-handoff-deliverable-sections`）**又撞了一次**。本文件存在的理由逐字
//   就是「不抽，第 2 条到第 N 条每条都要重付一遍」，这一条同样适用。
//
//   git 钩子运行时会往环境里注**绝对路径**的 `GIT_DIR` / `GIT_INDEX_FILE`
//   （2026-08-28 实测 dump：`GIT_DIR=…/.git/worktrees/<name>`、
//   `GIT_INDEX_FILE=…/next-index-<pid>.lock`）。子进程继承后，**`cwd` 不再决定 git 看哪棵树** ——
//   实测（替身仓库对照）：fixture 里跑 `git ls-files` 会返回**真仓库**的 376 份 `.md`，
//   而且 fixture 里的 `git init` / `git add` 会把 fixture 的文件**写进那个继承来的 index**
//   （替身仓库事后实测多出 `scripts/audit-*.mjs` 两份 + `plain.md`）。
//   ⇒ 在真 pre-commit 里，那个 index 就是**正在进行的这次提交**的 lock 文件。
//   连带后果实测：同一次 pre-commit 里另外三份**本轮一个字没改**的测试
//   （`audit-deliverable-open-questions` / `audit-deploy-marker` / `shipped-import-closure`
//   的「真仓库非空过钉」）一起转红 —— 它们读的是被污染的那份 index。
//   ⇒ **别把那种红读成「它们本来就红」**：换个环境（替身仓库单独跑）它们也红，但**红的原因
//   不是同一个** —— 这正是本仓「红的原因不是判据」那一类陷阱。
//
//   ⛔ **剥的范围与不剥的理由**：只剥 `GIT_` 前缀那一族，⛔ 不动 `PATH`/`HOME` 等
//   （git 需要它们）。对**不跑 git 的闸**（35 份 harness 用户里的 34 份）这是零行为变化。
//   对跑 git 的闸，它把「读真仓库」纠正回「读 fixture」—— 那正是 fixture 隔离的本意。
//
//   ⛔ **realpath 买到的是什么、⛔ 不是什么**：它只消除**测试环境**引入的 symlink
//   （tmpdir 那一层），让被测闸在 fixture 里跑得和在真仓库里一样。它**不**声明
//   「这条闸的入口守卫是 symlink-safe 的」—— 那是另一条判据，真源是
//   `scripts/lib/is-cli-entry.mjs` + `tests/is-cli-entry.test.ts`，且只对**随包**脚本成立。
//   ⇒ 一条用原始守卫的闸若哪天被加进 `files[]`，本 harness 的绿**不能**当它的凭据。
//
// 三条设计判据（都付过代价，⛔ 别顺手改）：
//   1. `scripts/lib` **软链回真仓库**，不拷贝 —— 拷贝会产生第二份会漂的副本，
//      而 lib 本身另有单测（IL 类）。软链让 fixture 里跑的 lib 永远是活源那份。
//   2. 断言必须**点名判据**（`expectGateRed` 的 `checks`），⛔ 不许只看退出码 ——
//      ENOENT / 语法错 / 缺依赖崩溃**也是非零**，只看退出码的红是假红，
//      它会让「判据其实没开火」的 fixture 一直绿着（本仓「判据必须取终态事实」纪律）。
//   3. 绿档断言要钉 fixture **自己的数**（`expectGateGreen` 的 `contains`），
//      ⛔ 不许只断言 `exit 0` —— 跑错了树（比如闸退回真仓库读）照样 exit 0，
//      而 fixture 自己的读数在真仓库里不可能同时对上。这是非空过的唯一凭据。
//
// 出处：本文件是 `tests/audit-status-consistency.test.ts` 里那套一次性 harness 的抽取版
//   （2026-08-25，[[INFRA-F138]] 的「第一步」）。抽它的理由不是「代码复用」，是
//   entry 逐字记的账：不抽，第 2 条到第 N 条每条都要重付一遍建 harness 的成本。
// -----------------------------------------------------------------------------
import { expect } from 'vitest'
import { spawnSync } from 'node:child_process'
import {
  copyFileSync,
  existsSync,
  mkdirSync,
  mkdtempSync,
  realpathSync,
  rmSync,
  symlinkSync,
  writeFileSync,
} from 'node:fs'
import { tmpdir } from 'node:os'
import { dirname, join, resolve } from 'node:path'

/** 真仓库根 —— 从本文件位置推（`tests/lib/` → 上两级），worktree 与主仓都成立。 */
export const REPO_ROOT = resolve(__dirname, '../..')

export type GateRun = { status: number; stdout: string; stderr: string }

const createdRoots: string[] = []

export type GateFixtureOptions = {
  /** 被测闸的仓库相对路径，如 `scripts/audit-doc-sync.mjs` */
  gate: string
  /** mkdtemp 前缀（便于失败时在 /tmp 里认出是谁的） */
  prefix: string
  /** 需要预建的目录（仓库相对），如 `['docs/internal']` */
  dirs?: string[]
  /** 要写进 fixture 的文件：仓库相对路径 → 内容 */
  files?: Record<string, string>
  /**
   * 软链回真仓库的目录（仓库相对），默认 `['scripts/lib']`。
   * 传 `[]` 显式关掉。⛔ 别把 `dist` 放进来（并行线会重建它 ⇒ 报假缺陷）。
   */
  linkDirs?: string[]
  /**
   * 从真仓库**拷**进 fixture 的额外文件（仓库相对）—— 被测闸 import 的同仓脚本。
   *
   * ⛔ 这里必须是**拷贝**，不能像 `linkDirs` 那样软链（2026-08-26 实证）：ESM 解析 symlink
   * 时走 realpath，被链进来的脚本的 `import.meta.url` 会落回**真仓库**，于是它按
   * `resolve(__dirname, '..')` 算出的 REPO_ROOT 也是真仓库 —— 那条闸就读真仓库的数据了，
   * fixture 完全失效且**照样绿**。`scripts/lib` 能软链，是因为那批 lib 不自己算 REPO_ROOT。
   *
   * 拷贝在**每次跑测试时**发生 ⇒ 内容永远是当时的活源，不构成长期副本（与判据 1 不冲突）。
   */
  copyFiles?: string[]
}

/**
 * 建一个假 repo root，把闸**拷**进去、把共享 lib **软链**进去，返回该 root 的绝对路径。
 * 闸文件不存在时当场抛 —— fail-closed，防「fixture 建错了但测试照样绿」。
 */
export function createGateFixture(opts: GateFixtureOptions): string {
  const gateAbs = resolve(REPO_ROOT, opts.gate)
  if (!existsSync(gateAbs)) {
    throw new Error(`createGateFixture: 闸不存在 ${opts.gate} —— 改名了？先修测试再跑`)
  }

  // ⛔ realpath 不可省 —— 见文件头「fixture root 必须 realpath」那段：macOS 的 tmpdir 在
  //    `/var`（symlink）下，不解析的话手写入口守卫的闸会静默不执行、exit 0、零输出。
  const root = realpathSync(mkdtempSync(join(tmpdir(), `${opts.prefix}-`)))
  createdRoots.push(root)

  // 闸自身（含其所在目录，如 scripts/ 或 figma-sync/）
  mkdirSync(join(root, dirname(opts.gate)), { recursive: true })
  copyFileSync(gateAbs, join(root, opts.gate))

  // 被测闸 import 的同仓脚本 —— 拷不链，理由见 GateFixtureOptions.copyFiles
  for (const rel of opts.copyFiles ?? []) {
    const src = resolve(REPO_ROOT, rel)
    if (!existsSync(src)) {
      throw new Error(`createGateFixture: copyFiles 指向不存在的 ${rel} —— 改名了？先修测试再跑`)
    }
    mkdirSync(join(root, dirname(rel)), { recursive: true })
    copyFileSync(src, join(root, rel))
  }

  for (const d of opts.dirs ?? []) mkdirSync(join(root, d), { recursive: true })

  for (const rel of opts.linkDirs ?? ['scripts/lib']) {
    const src = resolve(REPO_ROOT, rel)
    if (!existsSync(src)) throw new Error(`createGateFixture: linkDirs 指向不存在的 ${rel}`)
    mkdirSync(join(root, dirname(rel)), { recursive: true })
    symlinkSync(src, join(root, rel))
  }

  for (const [rel, content] of Object.entries(opts.files ?? {})) {
    mkdirSync(join(root, dirname(rel)), { recursive: true })
    writeFileSync(join(root, rel), content)
  }

  return root
}

/** 在 fixture root 里写 / 覆盖一个文件（造故障用）。 */
export function writeFixtureFile(root: string, rel: string, content: string): void {
  mkdirSync(join(root, dirname(rel)), { recursive: true })
  writeFileSync(join(root, rel), content)
}

/**
 * spawn 整个闸。归一化成 {status, stdout, stderr}，非零不抛。
 *
 * `args` = 闸自己的命令行参数（2026-08-26 补）。有一批闸是**一个脚本 × 多个 mode**，
 * mode 由 `process.argv[2]` 选，各自挂一个 npm key（如 `audit-icon-naming-rules.mjs`
 * 的 `category-enum` / `leaf-kebab` 两键）。不传参跑它 = 走 usage 分支 exit 2，
 * 测到的不是判据。⇒ 参数属于**被测闸的挂载形态**，harness 必须能传。
 * ⛔ 别拿它给闸喂测试专用开关 —— 只许传 npm script 里真的传了的那些值。
 *
 * 🔴 **2026-09-14：绿档的 `stderr` 原来是硬编码的 `''`，那是一整类判据的盲区。**
 *    原实现走 `execFileSync` 的 try 分支，退出码 0 时直接 `return { status: 0, stdout, stderr: '' }`
 *    —— 而 `execFileSync` 默认 `stdio[2] = 'inherit'` ⇒ 闸的 `console.warn` **打到了 vitest
 *    的控制台上，却从未进过返回值**。后果：任何「闸绿着、但应该 WARN」的判据在这套 harness 上
 *    **结构上测不到**；`expect(run.stderr).not.toContain(…)` 这类写法在绿档里是**恒真**的。
 *    （发现经过：给 V4 手改检测写红档时，`EQ=false`、warning 确实 push 了，输出里却一条没有。）
 *    ⇒ 换 `spawnSync`：两条路径都真捕获，且退出码取 `.status`（不经 shell，也不靠抛异常）。
 *    ⚠️ 副作用是闸的 WARN 不再直接刷在 vitest 控制台上 —— 要看就在断言里读 `run.stderr`。
 *
 * 🔴 **2026-09-15：`env` 第 4 参（可选）—— 给「要调外部 API 才走得到的通路」用。**
 *    WHY：有一族闸的绿档免责声明只在 **HTTP 错误码**下才打（变量端点 403 ⇒
 *    「本轮拿不到变量名 ⇒ 这几条判据会 under-report」）。它们的上游 API 基址是**写死常量**
 *    ⇒ 不可参数化，而 [[INFRA-F138]] 逐字封死了「为了让 harness 能用去改活闸」这条路。
 *    ⇒ 唯一零改动的入口是 `NODE_OPTIONS=--import <stub>` 预加载一个覆写 `globalThis.fetch`
 *      的模块，而那需要本函数能传 env。⛔ 别再自建第四份 spawn（处方逐字：不抽的话第 2 条
 *      到第 N 条每条都要重付一遍建 harness 的成本）。
 *    ⚠️ **传进来的 env 是 merge、不是替换**，且仍先过 `gitCleanEnv()` ——
 *      `GIT_*` 剥离那条纪律对这条路径同样必须成立（否则 fixture 隔离照样破）。
 *    ⛔ 别拿它喂**被测闸自己**的测试专用开关（同 `args` 那条纪律）：它只许放
 *      环境层的东西（`NODE_OPTIONS`、被测闸自己文档里要求的凭据环境变量之类）。
 *    ⚠️ 不传时行为**一字不变**（35 份既有用户零影响）。
 *
 *    🔴 **⛔ 别在本文件里写上游 API 的 host 字面量、也别写它的请求头名。**
 *      `audit:figma-env-single-source` 的 S5/S6 判据就是这两个标记，且它逐字声明
 *      「标记出现在**注释**里也算命中 ⇒ 会要求那个文件 import 共享模块。这是刻意的
 *      fail-closed 方向」。本 harness **不解析任何凭据**（只把调用方给的 env 原样透传）
 *      ⇒ 写进来只会制造一条误报。2026-09-15 本段初稿就因引用了那个常量让全量红 1 条。
 */
export function runGate(
  root: string,
  gate: string,
  args: string[] = [],
  env: NodeJS.ProcessEnv = {},
): GateRun {
  const r = spawnSync('node', [join(root, gate), ...args], {
    encoding: 'utf8', cwd: root, env: { ...gitCleanEnv(), ...env },
  })
  return { status: r.status ?? -1, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
}

/**
 * `process.env` 去掉整个 `GIT_` 前缀族 —— 见文件头「spawn 闸时必须剥掉继承的 `GIT_*`」。
 *
 * `runGate` 已经用了它，**测试自己**再跑 `git`（造 fixture 用的 `git init` / `git add`）时
 * 必须也用，否则那两条命令会落到继承来的 `GIT_DIR` 上、写进真仓库正在提交的 index。
 * ⛔ 别只剥 `GIT_DIR` —— `GIT_INDEX_FILE` 单独也足以改变 `git ls-files` 的答案。
 */
export function gitCleanEnv(): NodeJS.ProcessEnv {
  return Object.fromEntries(Object.entries(process.env).filter(([k]) => !k.startsWith('GIT_')))
}

/**
 * 断言闸**红且点名了这几条判据**。
 * ⛔ 只断言退出码非零是不够的 —— 崩溃也是非零（见文件头判据 2）。
 */
export function expectGateRed(
  run: GateRun,
  opts: { marker?: string; checks?: string[]; status?: number } = {},
): void {
  const out = `${run.stderr}\n${run.stdout}`
  expect(run.status).toBe(opts.status ?? 1)
  if (opts.marker) expect(out).toContain(opts.marker)
  for (const c of opts.checks ?? []) expect(out).toContain(c)
}

/**
 * 断言闸绿，且自印的读数是 **fixture 自己的**（非空过凭据，见文件头判据 3）。
 */
export function expectGateGreen(run: GateRun, opts: { contains?: string[] } = {}): void {
  expect(run.status).toBe(0)
  for (const c of opts.contains ?? []) expect(run.stdout).toContain(c)
}

/** 清掉本文件建过的所有 fixture root。放 afterEach / afterAll。 */
export function cleanupGateFixtures(): void {
  for (const r of createdRoots.splice(0)) rmSync(r, { recursive: true, force: true })
}
