// tests/audit-stale-anchors-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-stale-anchors.mjs` 的**整脚本回归面**（[[INFRA-F138]]，2026-08-27）。
//
// WHY 这条闸排在存量最前面（owner 2026-08-27 拍「开工」）：
//   它在 `package.json` 的 `files[]` 白名单里 ⇒ **随 npm 包发到每个 consumer 的 CI**，
//   假绿会跟着下一个版本分发出去；而它同时是随包那 7 条里**唯一三层挂载**的实心闸
//   （L4 pre-commit + L5 gh-ci + L5 gate-chain）—— 假绿代价在本仓也直接承重。
//
//   ⚠️ 它此前被归在「要先给 Figma API 做 stub 才能补」那一档，那是**误判**：量具的 P2
//   分类器旧判据 `_TOKEN\b` 撞上了它 `ENV_ALLOWLIST` 里的字符串常量 `'FIGMA_TOKEN'`
//   （:310）。它实际零凭据零网络，import 只有 node:fs / node:url / node:path
//   + `./lib/is-cli-entry.mjs`。分类器已于同日收窄（见 `gate-regression-face-inventory.mjs`）。
//
// ⛔ 与 `tests/stale-anchors.test.ts` 的分工（**别合并**）：那份 import 纯函数，覆盖
//   判据逻辑；本份 spawn 整个脚本，覆盖**接线 + main() 出口顺序 + 8 处 fail-closed 前置**。
//   本仓已量过两者的差：摘掉接线时只 import 符号的测试**全绿**。
//
// ⚠️ 本文件自身受被测闸约束（它扫 `tests/*.ts` 找退役号）：
//   ⛔ 不得在本文件里写 `§` + 退役规则号 —— 那会让真仓库的闸对本文件报红。
//   fixture 里要造的退役号引用，一律写进 fixture **自己那棵树**的文件，不写在本文件源码里。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { rmSync } from 'node:fs'
import { join } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
} from './lib/gate-fixture-root'

const GATE = 'scripts/audit-stale-anchors.mjs'

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 退役号真源表。`collectRetiredIds` 要求：表头 cells[0] === '旧 ID' 且含 '现行落点'，
// 且至少 3 列。⛔ 表里写**裸号**（不带 §）—— 带 § 会让它自己变成一个 rule-ref 锚点。
const LEGACY_MAP = [
  '## Legacy ID Map',
  '',
  '| 旧 ID | 说明 | 现行落点 |',
  '| --- | --- | --- |',
  '| M9999 | 已退役（fixture 专用，闸源码零命中）| design-process M9999 |',
  '',
].join('\n')

/** 一份干净的 fixture 树：零孤儿，且闸里硬编码的两条豁免都**真命中**。 */
function cleanFixture(extra: Record<string, string> = {}, extraDirs: string[] = []) {
  return createGateFixture({
    gate: GATE,
    prefix: 'stale-anchors-fx',
    // ⛔ linkDirs 用默认（软链 scripts/lib）—— 闸 import `./lib/is-cli-entry.mjs`，
    //    传 [] 会让它 ENOENT 崩掉，而**崩溃也是非零退出** ⇒ 红得理由不对。
    dirs: [
      // fail-closed #4：dirIndex 里必须按段边界找得到 `_archive/retrospection`
      'docs/_archive/retrospection',
      // 供下方豁免 ② 的 `../_prompts/foo.md` 匹配到目录分量（文件不放 ⇒ 判 orphan ⇒ 命中豁免）
      'docs/internal/_prompts',
      // 🔴 **这个目录现在是「fence 内整段跳过」那条行为的活探针**（2026-08-31 改用途）：
      //    它让 `./assets/tvu-web-components.js` 的目录分量在 dirIndex 里存在，而那个文件不放
      //    ⇒ 一旦 fence 跳过失效，该路径立刻落进 code-span-path 的显式相对分支被报死链。
      //    ⛔ 别因为「豁免 ① 已删、这目录看着没用了」就删它 —— 删了 fence 跳过就没有任何回归面。
      //    正/反两面见下方 describe『fenced code block 内整段跳过』。
      // ⚠️ fixture 的 dirIndex 只覆盖被扫树，必须建在 `docs/` 下才进得了索引
      //    （真仓库那边这个分量来自 `playground-dist/assets/`）。
      'docs/assets',
      ...extraDirs,
    ],
    files: {
      'package.json': JSON.stringify({ scripts: { 'audit:stale-anchors': `node ${GATE}` } }),
      '.env.example': 'FIXTURE_ONLY_VAR=1\n',
      // LIVE_TREE_EXTRA_FILES 列了它，缺了闸会抛（fail-closed，非静默跳过）
      'AGENTS.md': '# Agents\n\n没有锚点。\n',
      // SCAN_FILES 三份（全六类判据都跑）
      'docs/meta-rules.md': '# Meta\n\n## 触发器 A：示例\n\n没有孤儿锚点。\n',
      'docs/internal/mockup-conventions.md': `# Mockup\n\n${LEGACY_MAP}`,
      'docs/internal/code-conventions.md': '# Code\n\n## R1 示例\n\n没有孤儿锚点。\n',
      // DEF_FILES 余下三份（rule-ref 注册表用，比 SCAN 宽）
      'docs/internal/design-process.md': '# Process\n',
      'docs/internal/figma-technical-reference.md': '# FigmaRef\n',
      'docs/internal/domain-tvu.md': '# Domain\n',
      // RETIRED_SCAN_SURFACES 的 tests/ 那一面：空集会 fail-closed
      'tests/placeholder.ts': 'export const fixtureOnly = 1\n',
      // ── 闸里硬编码的两条 LIVE_TREE_EXEMPTIONS，fixture 必须让它们**命中** ──
      //    否则 shrink-only 会判「豁免已失效」而红，那样测别的判据全被这条盖住。
      // 🔴 这一份**不再**是豁免素材（那条豁免 2026-08-31 已删 —— 它在干净 checkout 上无对象、
      //    反而让闸恒红）。它现在镜像真仓库改后的形态：两条包内路径放在 fenced block 里，
      //    靠「``` 内整段跳过」而不是靠豁免。配合上面 `docs/assets` 探针形成正/反对照。
      'docs/CLAUDE_DESIGN_SETUP.md': [
        '# Claude Design Setup',
        '',
        '包内布局（fenced ⇒ 本闸整段跳过，不是本仓路径）：',
        '',
        '```text',
        './tokens/variables.css',
        './assets/tvu-web-components.js',
        '```',
        '',
      ].join('\n'),
      'docs/superpowers/plans/2026-08-12-path-a-legacy-host-increment-rule.md':
        '# Plan\n\n[design-process](./design-process.md)\n',
      'docs/superpowers/specs/2026-08-05-infra-f96-lifecycle-scan-face-extension-design.md':
        '# Spec\n\n相对写法 `../_prompts/foo.md` 是示意路径。\n',
      ...extra,
    },
  })
}

describe('绿档（非空过凭据：钉 fixture 自己的读数，⛔ 不只看 exit 0）', () => {
  it('干净 fixture ⇒ exit 0，且自印的是 fixture 自己的扫描面，不是真仓库的', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    // 这条与下面 expectGateGreen 的 status 断言重复，**刻意保留**：绿档一旦被 fixture 的
    // 意外孤儿打红，光看「expected 1 to be 0」无从下手；把闸的原始输出带进失败消息，
    // 下一个接手的人一眼看到是哪条判据开的火。（本轮就是靠它定位到探针号撞被测闸自身。）
    expect(r.status, `闸的 stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    expectGateGreen(r, {
      contains: [
        'audit-stale-anchors OK — 所有锚点目标存在，零孤儿',
        // fixture 自己的现行层份数：docs/ 下 9 份 md + AGENTS.md = 10
        //（2026-08-28 从 9 → 10：fixture 多了一份 docs/CLAUDE_DESIGN_SETUP.md；
        //  2026-08-31 那份的用途从「豁免 ① 素材」改成「fence 跳过的探针」，份数不变）
        // （docs/_archive/ 被前缀排除；真仓库那边是数百份 ⇒ 跑错树对不上这个数）
        '现行层扫描面（跑 path + code-span-path + fragment）：10 份',
        // 余下两条硬编码豁免都命中 ⇒ shrink-only 不报
        //（2026-08-31 从 3 → 2：删掉了 CLAUDE_DESIGN_SETUP → ./assets/… 那条空转豁免）
        '具名豁免 2 条（shrink-only，空 = 终态）',
      ],
    })
    // ⛔ 反向：真仓库的读数绝不该出现在 fixture 的输出里
    expect(r.stdout).not.toContain('docs/STATUS.md')
  })
})

describe('七类判据各自开火（每条都点名判据，⛔ 不只看退出码 —— 崩溃也是非零）', () => {
  it('第 1 类 path：markdown 链接目标不存在', () => {
    const root = cleanFixture({
      'docs/meta-rules.md': '# Meta\n\n[没了](./nope-path.md)\n',
    })
    expectGateRed(runGate(root, GATE), {
      marker: 'audit-stale-anchors FAIL',
      checks: ['[path]', './nope-path.md', '目标文件不存在'],
    })
  })

  it('第 2 类 fragment：目标文件在、heading slug 不在', () => {
    const root = cleanFixture({
      'docs/meta-rules.md': '# Meta\n\n[没这段](./internal/code-conventions.md#no-such-slug)\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[fragment]', 'no-such-slug', 'heading slug 不存在'],
    })
  })

  it('第 3 类 rule-ref：整族规则定义已消失', () => {
    const root = cleanFixture({
      'docs/meta-rules.md': '# Meta\n\n见 §R7 的说明。\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[rule-ref]', 'R7', '规则定义不存在'],
    })
  })

  it('第 4 类 script：反引号里的 npm key 不存在', () => {
    const root = cleanFixture({
      'docs/meta-rules.md': '# Meta\n\n跑 `audit:no-such-key` 校验。\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[script]', 'audit:no-such-key', 'package.json scripts 无此 key'],
    })
  })

  it('第 5 类 env：变量不在 .env.example / allowlist', () => {
    const root = cleanFixture({
      'docs/meta-rules.md': '# Meta\n\n读 process.env.NOT_DECLARED_ANYWHERE 决定行为。\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[env]', 'NOT_DECLARED_ANYWHERE', '.env.example / allowlist 无此变量'],
    })
  })

  it('第 6 类 retired-id：代码层引用了 Legacy ID Map 里的退役号', () => {
    // ⚠️ 探针写进 fixture 树里的文件，⛔ 不写在本测试文件源码里（本文件也在闸的扫描面上）。
    const root = cleanFixture({
      'tests/placeholder.ts': ['export const fixtureOnly = 1', '// 见 §M9999 的说明'].join('\n'),
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[retired-id]', 'M9999', '该号已退役'],
    })
  })

  it('第 6 类的族根那一支：M9999.1 本身不在表里，靠族根 M9999 命中', () => {
    // 这一支缺了就抓不到 2026-08-21 修的那个真 bug（退役号带子号）。
    const root = cleanFixture({
      'tests/placeholder.ts': ['export const fixtureOnly = 1', '// 见 §M9999.1 的说明'].join('\n'),
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[retired-id]', 'M9999.1', '该号已退役'],
    })
  })

  it('第 4b 类 code-span-path：显式相对 + 目录分量在本仓 + 文件不在', () => {
    const root = cleanFixture(
      { 'docs/meta-rules.md': '# Meta\n\n改 `./sub/gone.md` 那份。\n' },
      ['docs/sub'],
    )
    expectGateRed(runGate(root, GATE), {
      checks: ['[code-span-path]', './sub/gone.md', '目标文件不存在'],
    })
  })

  it('现行层（SCAN_FILES 之外的 docs/**）：死链同样被抓，kind 带 live: 前缀', () => {
    const root = cleanFixture({
      'docs/some-live-doc.md': '# Live\n\n[没了](./gone-from-live.md)\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[live:path]', './gone-from-live.md', 'docs/some-live-doc.md'],
    })
  })
})

describe('fail-closed 前置（分母塌了必须红，⛔ 不许「跳过 = 通过」）', () => {
  it('SCAN_FILES 缺一份 ⇒ 硬 FAIL 并点名是哪份', () => {
    const root = cleanFixture()
    rmSync(join(root, 'docs/meta-rules.md'))
    expectGateRed(runGate(root, GATE), {
      checks: ['✗ 扫描文件缺失：docs/meta-rules.md'],
    })
  })

  it('读不到 package.json ⇒ 硬 FAIL（不是「script 判据静默跳过」）', () => {
    const root = cleanFixture()
    rmSync(join(root, 'package.json'))
    expectGateRed(runGate(root, GATE), {
      checks: ['✗ 前置缺失：读不到 package.json scripts'],
    })
  })

  it('Legacy ID Map 解析出 0 行 ⇒ 硬 FAIL（把表删空正是最该被拦的那一刻）', () => {
    const root = cleanFixture({ 'docs/internal/mockup-conventions.md': '# Mockup\n\n表没了。\n' })
    expectGateRed(runGate(root, GATE), {
      checks: ['解析不出任何', 'retired-id 判据会空转成假绿'],
    })
  })

  it('RETIRED_SCAN_SURFACES 任一面塌成空集 ⇒ 硬 FAIL（扫描面比缺陷面窄正是它要治的病）', () => {
    const root = cleanFixture()
    rmSync(join(root, 'tests/placeholder.ts'))
    expectGateRed(runGate(root, GATE), {
      checks: ['✗ 前置缺失：tests/ 下零个 .ts', '扫描面塌成空集'],
    })
  })

  it('LIVE_TREE_EXTRA_FILES 列的文件不存在 ⇒ 抛，不静默缩小扫描面', () => {
    const root = cleanFixture()
    rmSync(join(root, 'AGENTS.md'))
    const r = runGate(root, GATE)
    expect(r.status).not.toBe(0)
    // 点名判据：⛔ 只看退出码不够，这一条走的是 throw 不是 process.exit
    expect(`${r.stderr}\n${r.stdout}`).toContain('LIVE_TREE_EXTRA_FILES 列了不存在的文件：AGENTS.md')
  })
})

describe('fenced code block 内整段跳过（⛔ 别只测正面 —— 正面绿也可能是探针本身死了）', () => {
  // 🔴 WHY 这一对 2026-08-31 才补：删掉 CLAUDE_DESIGN_SETUP → `./assets/…` 那条空转豁免时，
  //    替代它的正是「把那段包内布局放进 fenced block」这一条闸行为，而它当时**零回归面**
  //    —— 改坏了没有任何测试会红。fixture 的 `docs/assets` 目录就是为这一对留的探针。
  it('正面：包内路径在 fenced block 内 ⇒ 不报，尽管目录分量在、文件不在', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    expect(r.status).toBe(0)
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('./assets/tvu-web-components.js')
  })

  it('反面（证明上一条的探针是活的）：同一路径移出 fenced block ⇒ 立刻报死链', () => {
    const root = cleanFixture({
      'docs/CLAUDE_DESIGN_SETUP.md':
        '# Claude Design Setup\n\n包内布局：`./assets/tvu-web-components.js`。\n',
    })
    expectGateRed(runGate(root, GATE), {
      checks: ['[live:code-span-path]', './assets/tvu-web-components.js', '目标文件不存在'],
    })
  })
})

describe('shrink-only 豁免（表只许缩，且由闸自己宣布缩到哪了）', () => {
  it('豁免登记着、本轮却不再命中 ⇒ FAIL 并要求删行', () => {
    const root = cleanFixture()
    // 把豁免 ① 命中的那条死链改成不再是死链之外的办法：直接删掉那份文件
    rmSync(join(root, 'docs/superpowers/plans/2026-08-12-path-a-legacy-host-increment-rule.md'))
    expectGateRed(runGate(root, GATE), {
      checks: ['条现行层豁免已失效', 'LIVE_TREE_EXEMPTIONS', '给它留一个永久空洞'],
    })
  })

  it('正向锚点：豁免命中时不报（否则上一条会因为「总是红」而空过）', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    expect(r.status).toBe(0)
    expect(`${r.stderr}\n${r.stdout}`).not.toContain('条现行层豁免已失效')
  })
})

describe('接线（这一层只 import 判据函数结构上看不见）', () => {
  it('CLI 入口真的调用了 main —— 摘掉 isCliEntry 那行 ⇒ 零输出 exit 0', () => {
    // 🔴 本条钉的是「程序自己最后那句话是输出的最后一行」，⛔ 不是 exit code：
    //    entry guard 恒假时闸**整个不执行** ⇒ exit 0 且 stdout 双空，那正是本仓
    //    2026-08-26 在 pnpm consumer 上量到的「CI 绿了几个月」的形态。
    const root = cleanFixture()
    const r = runGate(root, GATE)
    expect(r.status).toBe(0)
    expect(r.stdout.trim()).not.toBe('')
    const lines = r.stdout.trim().split('\n')
    expect(lines[lines.length - 1]).toContain('audit-stale-anchors OK')
  })

  it('阻断出口真的接上了：有 finding 时 exit 1，且 finding 明细印在 stderr', () => {
    const root = cleanFixture({ 'docs/meta-rules.md': '# Meta\n\n[没了](./nope-exit.md)\n' })
    const r = runGate(root, GATE)
    // 摘掉 `process.exit(1)` 那一支 ⇒ 明细照印而 status 变 0，本条抓的就是那个差
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('个过期锚点（孤儿）')
    expect(r.stderr).toContain('./nope-exit.md')
  })

  it('coverage 自印在绿档也出现（判据跑没跑过的唯一可见凭据）', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    expect(r.stdout).toContain('audit-stale-anchors — coverage（实际检查锚点数）')
    expect(r.stdout).toContain('TOTAL')
    expect(r.stdout).toContain('fragment 分形态')
    expect(r.stdout).toContain('code-span-path 分形态')
  })
})

describe('fixture 有效性自证（⛔ 防「fixture 建错了但测试照样绿」）', () => {
  it('闸读的是 fixture 的树：改 fixture 的 package.json，script 判据跟着变', () => {
    // 正向：key 在 ⇒ 绿
    const ok = cleanFixture({
      'package.json': JSON.stringify({ scripts: { 'audit:fixture-only-key': 'noop' } }),
      'docs/meta-rules.md': '# Meta\n\n跑 `audit:fixture-only-key` 校验。\n',
    })
    expectGateGreen(runGate(ok, GATE), { contains: ['audit-stale-anchors OK'] })

    // 反向：同一个 key 在真仓库里不存在 ⇒ 若闸读的是真仓库，这条会红
    const bad = cleanFixture({
      'package.json': JSON.stringify({ scripts: { 'audit:something-else': 'noop' } }),
      'docs/meta-rules.md': '# Meta\n\n跑 `audit:fixture-only-key` 校验。\n',
    })
    expectGateRed(runGate(bad, GATE), { checks: ['audit:fixture-only-key'] })
  })

  it('产物/读数不回落真仓库：真仓库根文件名不出现在 fixture 输出里', () => {
    const root = cleanFixture()
    const r = runGate(root, GATE)
    // 真仓库 SCAN_FILES 那三份内容极长，coverage TOTAL 会是数千；fixture 是个位数量级
    const m = r.stdout.match(/TOTAL\s+(\d+)/)
    expect(m).not.toBeNull()
    expect(Number(m![1])).toBeLessThan(100)
  })
})
