// tests/audit-artifact-routing-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-artifact-routing.mjs` 的**整脚本回归面**，重点是它那两条
// **绿档 fail-open WARN 通路**（声明解析失败 ⇒ 只警告、不阻塞）。
//
// WHY 这两条通路值得单独钉（⛔ 不是「顺手补个测试」）：
//   闸的头注释逐字把 fail-open 写成误报防控的一环 ——「解析失败=fail-open warn 不阻塞」。
//   ⇒ 作者**有意**让「声明写坏了」这件事**不改变退出码**，代价是那条 WARN 成了
//   **绿档唯一的免责声明**：这次的 exit 0 里，有一个制品的路由根本没被校验过。
//   这类「闸绿着、但应该 WARN」的判据一旦退化成静默，**没有任何退出码会变**，
//   于是没有任何人会发现 —— 而制品路由本来就是「防死唤醒词漂移」的那道闸。
//
// ⚠️ **本闸与同族其它闸不同的一点，直接决定了本面只能靠 fixture**：
//   2026-09-14 真仓库实跑 = **exit 0 且 stderr 零字节** ⇒ 两条 WARN 通路在现树
//   **双双零触发**。⇒ ⛔ 不存在「跑一下真仓库就能看见它」的验证方式，
//   也⛔ 别把真仓库的绿读成「这两条通路是好的」。
//
// ⛔ 与 `tests/artifact-routing.test.ts` 的分工（⛔ 别读成重复）：
//   那份 import 四个纯函数测**判定逻辑**，其中 `:52` / `:95` 已经钉了
//   `status === 'warn'` 这个返回值 —— 那一层**有覆盖**。
//   本份 spawn 整个脚本，覆盖的是只有整脚本才看得见、而纯函数结构上看不到的三样：
//     · 入口守卫 → runner 的接线（本闸用**手写**守卫
//       `process.argv[1] === fileURLToPath(import.meta.url)`；守卫恒假时是
//       「exit 0 且零输出」—— 与「跑完全绿」逐字同码，只有钉末行分得开）
//     · `console.warn` 那条**通路**本身（纯函数只返回 `{status:'warn', reason}`，
//       打不打得出去、打到哪条流上，它不知道）
//     · `warned` 计数**跨 P1/P2 两段累加**（两段各有一个 `console.warn` 调用点，
//       而汇总行只有一处 ⇒ 少跑一段时明细少一条、汇总数也跟着少）
//
// ⛔ 断言必须**点名 WARN 的原文**，⛔ 不许写 `expect(stderr).not.toBe('')`：
//   fixture 不是 git 仓时，闸内若有 `execSync git` 会把 `fatal: not a git repository`
//   透传进 stderr ⇒ 「非空」这类断言**恒真**。本闸当前不跑 git，但那条纪律对整族成立。
//
// ⚠️ **两条流各持一半证据，断言时⛔ 别搞混**（逐字读闸的 runner 段确认过）：
//     · WARN 明细 `⚠️  <path>: <reason>` → `console.warn` → **stderr**
//     · 汇总行 `(N fail-open warn，不阻塞)` 与末行 `✅ / ❌ artifact-routing …`
//       → `console.log` → **stdout**（⚠️ 红档的末行**也在 stdout**，⛔ 不在 stderr）
//
// ⚠️ **fixture 的 `docs/WAKE-WORDS.md` 是判据的输入，写字要当心**：闸的判据是
//   `wakeWordsContent.includes(w)` —— **整份文件的裸子串匹配**，不解析结构
//   ⇒ 注册表里**任何位置**出现那个唤醒词字面，就等于替它登记了路由。
//   这既是坑，也正是红档臂的**开关**（见 `WAKE_WORDS` 与 `SKILL_UNREGISTERED`）。
//
// ⛔ 污染纪律：本文件除被测闸自身外**不出现任何别的闸的 `.mjs` basename、也不出现任何
//   别的 `audit:` npm key** —— 否则按名字扫的量具会把那条闸误报成「已覆盖」。
//   fixture 的制品名与唤醒词一律用真仓库不可能撞上的 `fx-` / `@FX …-90` 形态
//   （2026-09-14 实测真仓库零命中），⛔ 别用 `@TVU mockup` 这类活唤醒词。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { createGateFixture, runGate, cleanupGateFixtures } from './lib/gate-fixture-root'

const GATE = 'scripts/audit-artifact-routing.mjs'

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 真仓库现取（2026-09-14 @`88e09c4f`）：扫 15 文件型 / 7 校验通过 / 8 跳过，
// 扫 4 规则型声明 / 4 校验通过 / 0 跳过，warned = 0。
// fixture 的数（3 / 1 / 1 与 2 / 1 / 0，warned = 2）与它们**全不相同** ——
// 绿档钉的就是这几个数，闸若退回真仓库读，本组必炸。

/** 注册表。⚠️ 裸子串匹配 ⇒ 这里出现什么字面，就等于替谁登记了路由。 */
const WAKE_WORDS = [
  '# Fixture 唤醒词注册表',
  '',
  '| 唤醒词 | 路由到 |',
  '|---|---|',
  '| `@FX wake-90` | skills/fx-alpha/SKILL.md |',
  '| `@FX rule-90` | docs/meta-rules.md |',
  '',
].join('\n')

const fm = (body: string) => `---\nname: fx\n${body}\n---\n\n正文。\n`

/** P1 · ok 档：声明完整且唤醒词已注册。 */
const SKILL_OK = fm('wakeable: true\nwakeWords: ["@FX wake-90"]')

/**
 * P1 · **被测通路 1**：`wakeable: true` 但**整行缺** `wakeWords:`
 * ⇒ 走 `parseSelfDeclaration` 的 `if (!wm)` 分支 ⇒ parseError ⇒ warn（fail-open）。
 */
const SKILL_PARSE_ERROR = fm('wakeable: true')

/** P1 · skip 档：根本没写 `wakeable` ⇒ declared:false ⇒ 静默不校验（⛔ 不是 warn）。 */
const PROMPT_NO_DECL = fm('description: 没有 wakeable 声明')

/** P1 · 红档用：声明完整，但唤醒词**不在**注册表 ⇒ unrouted ⇒ exit 1。 */
const SKILL_UNREGISTERED = fm('wakeable: true\nwakeWords: ["@FX unregistered-90"]')

/** P2 · ok 档。 */
const META_RULES_OK = [
  '# Fixture Meta Rules',
  '',
  '### 规则 R90',
  '',
  '<!-- artifact-routing: wakeable=true; wakeWords=["@FX rule-90"] -->',
  '',
  '正文。',
  '',
].join('\n')

/**
 * P2 · **被测通路 2**：注释写了 `artifact-routing:` 却**缺 `wakeable=`**
 * ⇒ 走 `parseDocRuleDeclaration` 的 `if (!wm)` 分支 ⇒ parseError ⇒ warn（fail-open）。
 *
 * ⚠️ 刻意走**与通路 1 不同的那个分支**（那条缺的是 `wakeWords`，这条缺的是 `wakeable`）
 * ⇒ 两条明细同时出现，才能证明两段各自的 `console.warn` 调用点都开火了。
 * ⚠️ 它也正是闸头注释 `:97-98` 强调的语义：显式写了注释 = 作者有意声明，
 *    写不全算 parseError（fail-open），⛔ 不是「根本没写注释」那种静默 skip。
 */
const MOCKUP_PARSE_ERROR = [
  '# Fixture Mockup Conventions',
  '',
  '### 规则 M90',
  '',
  '<!-- artifact-routing: wakeWords=["@FX orphan-90"] -->',
  '',
  '正文。',
  '',
].join('\n')

/** 对照臂用：同一份文档，只把缺的 `wakeable=true` 补回去 ⇒ 该条 warn 归零。 */
const MOCKUP_FIXED = MOCKUP_PARSE_ERROR.replace(
  '<!-- artifact-routing: wakeWords=["@FX orphan-90"] -->',
  '<!-- artifact-routing: wakeable=false -->',
)

// ── WARN 原文（逐字取自 `scripts/audit-artifact-routing.mjs` 的 runner 段）──
// ⚠️ `⚠️` 与路径之间是**两个空格**（模板串 `` `⚠️  ${…}: ${…}` ``），⛔ 别手打成一个。
const WARN_P1 = '⚠️  skills/fx-beta/SKILL.md: wakeable:true 但 wakeWords 解析失败（fail-open）'
const WARN_P2 =
  '⚠️  docs/internal/mockup-conventions.md: artifact-routing 声明解析失败（fail-open）'
/** 汇总行走 **stdout**（`console.log`）—— 两段累加的唯一凭据。 */
const WARN_TALLY = '(2 fail-open warn，不阻塞)'

type FxOpts = { mockup?: string; extraSkill?: string }

/**
 * 三个文件型制品（1 ok / 1 parseError / 1 无声明）+ 两份 doc 真源（1 ok / 1 parseError）。
 *
 * ⛔ `linkDirs: []` 是**显式**关掉、⛔ 不是照抄默认：实测本闸只 import `node:fs` /
 * `node:url` / `node:path` 三个内置模块，**零 `scripts/lib` 依赖** ⇒ 软链进来只是噪声。
 * （同族的别的闸 import 了同仓 lib，那些必须留默认软链，否则 ENOENT 崩掉 ——
 * 而**崩溃也是非零退出**，会红得理由不对。）
 *
 * ⚠️ `docs/internal/design-process.md` **刻意不建** —— 闸对读不到的真源走
 * `try { … } catch { continue }` 静默跳过。它在这里是第三条通路的阴性对照：
 * 「文件不存在」⛔ 不该被算成 parseError（那会把 warned 顶到 3、汇总行当场变）。
 */
function fixture(opts: FxOpts = {}): string {
  const files: Record<string, string> = {
    'docs/WAKE-WORDS.md': WAKE_WORDS,
    'skills/fx-alpha/SKILL.md': SKILL_OK,
    'skills/fx-beta/SKILL.md': SKILL_PARSE_ERROR,
    'docs/internal/_prompts/fx-gamma.prompt.md': PROMPT_NO_DECL,
    'docs/meta-rules.md': META_RULES_OK,
    'docs/internal/mockup-conventions.md': opts.mockup ?? MOCKUP_PARSE_ERROR,
  }
  if (opts.extraSkill) files['skills/fx-delta/SKILL.md'] = opts.extraSkill
  return createGateFixture({ gate: GATE, prefix: 'artifact-routing-fx', linkDirs: [], files })
}

describe('A 绿档 fail-open WARN 通路（本文件存在的理由：exit 0 的那一趟里，免责声明有没有被说出来）', () => {
  it('A1 🔴 两条声明各坏一种 ⇒ 仍 exit 0，且 stderr **逐字**打出两条 WARN 明细', () => {
    const r = runGate(fixture(), GATE)
    // 绿档：fail-open 的语义就是「不拦，只声明」—— 退出码变了反而是回归。
    expect(r.status, `闸的 stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    // ⛔ 点名原文，⛔ 不写 not.toBe('')（见文件头：git 透传会让「非空」恒真）
    expect(r.stderr).toContain(WARN_P1)
    expect(r.stderr).toContain(WARN_P2)
    // must-not-hit：ok 档与「无声明」档都不该被报 ⇒ 证明 WARN 不是「见制品就打」
    expect(r.stderr).not.toContain('fx-alpha')
    expect(r.stderr).not.toContain('fx-gamma')
  })

  it('A2 `warned` 跨 P1/P2 两段累加 ⇒ stdout 的汇总行是 **2**，⛔ 不是 1', () => {
    // 少跑任一段时这个数会掉到 1，而 A1 仍会绿（它只查 toContain）⇒ 这条单独钉。
    const r = runGate(fixture(), GATE)
    expect(r.stdout).toContain(WARN_TALLY)
    expect(r.stdout).toContain('\n✅ artifact-routing OK')
  })

  it('A3 WARN 明细走 **stderr**、汇总行走 **stdout** —— 两条流各持一半证据', () => {
    const r = runGate(fixture(), GATE)
    // 换成 console.log 时本条红（而 A1 也红）；换成 console.warn 时下半条红
    expect(r.stdout).not.toContain('wakeWords 解析失败')
    expect(r.stdout).not.toContain('artifact-routing 声明解析失败')
    expect(r.stderr).not.toContain('fail-open warn，不阻塞')
  })

  it('A4 非空过凭据：自印的分母是 **fixture 自己的**，不是真仓库的', () => {
    const r = runGate(fixture(), GATE)
    expect(r.stdout).toContain('(扫 3 文件型: 1 校验通过 / 1 跳过[无声明或豁免])')
    expect(r.stdout).toContain('(扫 2 规则型声明: 1 校验通过 / 0 跳过[豁免])')
    // ⛔ 反向：真仓库读数绝不该出现（闸退回真仓库读 ⇒ 本条必炸）
    expect(r.stdout).not.toContain('扫 15 文件型')
    expect(r.stdout).not.toContain('扫 4 规则型声明')
    expect(r.stdout).not.toContain('@TVU')
  })
})

describe('B 对照臂（⛔ 缺这一组，A 组分不清「真的打了 WARN」与「恒真」）', () => {
  it('B1 🔴 把缺的 `wakeable=` 补回去 ⇒ 仍 exit 0，但 P2 那条 WARN **消失**', () => {
    const r = runGate(fixture({ mockup: MOCKUP_FIXED }), GATE)
    expect(r.status, `闸的 stderr:\n${r.stderr}`).toBe(0)
    expect(r.stderr).not.toContain(WARN_P2)
    // P1 那条**仍在** ⇒ 证明开关只作用在被改的那一段，⛔ 不是「整个 WARN 通路被关掉了」
    expect(r.stderr).toContain(WARN_P1)
    // 汇总行跟着从 2 掉到 1 ⇒ 两条流的读数互相印证
    expect(r.stdout).toContain('(1 fail-open warn，不阻塞)')
  })

  it('B2 🔴 开关只在声明那一行 —— 制品正文一字未动，WARN 就从 2 归 0，stderr **零字节**', () => {
    // 全修好版：坏的那份 skill 也换成完整声明。
    const clean = createGateFixture({
      gate: GATE,
      prefix: 'artifact-routing-fx-clean',
      linkDirs: [],
      files: {
        'docs/WAKE-WORDS.md': WAKE_WORDS,
        'skills/fx-alpha/SKILL.md': SKILL_OK,
        'skills/fx-beta/SKILL.md': SKILL_OK,
        'docs/internal/_prompts/fx-gamma.prompt.md': PROMPT_NO_DECL,
        'docs/meta-rules.md': META_RULES_OK,
        'docs/internal/mockup-conventions.md': MOCKUP_FIXED,
      },
    })
    const r = runGate(clean, GATE)
    expect(r.status, `闸的 stderr:\n${r.stderr}`).toBe(0)
    // 本闸绿档不跑 git、无其它 stderr 来源 ⇒ 这里是真的零字节
    expect(r.stderr).toBe('')
    expect(r.stdout).not.toContain('fail-open warn，不阻塞')
    // 正向锚点：判据链跑完了，且分母没变（差别**只**发生在声明那几行）
    expect(r.stdout).toContain('(扫 3 文件型: 2 校验通过 / 1 跳过[无声明或豁免])')
  })
})

describe('C 接线（这一层只 import 判据函数结构上看不见）', () => {
  it('C1 🔴 入口守卫真的开火了 —— 恒假时会是「exit 0 且零输出」，与全绿逐字同码', () => {
    // 本闸用手写守卫 `process.argv[1] === fileURLToPath(import.meta.url)`，
    // 而 macOS 的 tmpdir 在 symlink 下 ⇒ root 不 realpath 时它恒假、runner 整个不执行。
    // 共享 harness 已在 root 上 realpathSync，本条是那行的回归钉。
    // ⛔ 判据取**终态事实**：末行必须是闸自己的收尾句。
    const r = runGate(fixture(), GATE)
    expect(r.status).toBe(0)
    expect(r.stdout.trim()).not.toBe('')
    const lines = r.stdout.trim().split('\n')
    expect(lines[lines.length - 1]).toBe('✅ artifact-routing OK')
  })

  it('C2 阻断出口接上了：唤醒词未注册 ⇒ exit 1 并点名那个制品', () => {
    const r = runGate(fixture({ extraSkill: SKILL_UNREGISTERED }), GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain(
      '❌ skills/fx-delta/SKILL.md: 唤醒词未在 WAKE-WORDS.md 注册 → @FX unregistered-90',
    )
    // 后半条抓另一种假红：console.error 打完才崩 ⇒ 退出码碰巧也非零、点名也在，
    // 只有「末行是不是闸自己的收尾句」分得开（崩溃时末行是 `Node.js vXX` 栈尾）。
    // ⚠️ 本闸的末行走 **stdout**（`console.log`），红档也一样 ⇒ ⛔ 别去 stderr 找。
    const outLines = r.stdout.trim().split('\n')
    expect(outLines[outLines.length - 1]).toBe('❌ artifact-routing: 1 制品路由不全')
  })

  it('C3 🔴 FAIL 与 WARN **并存**时两段都执行 —— WARN 不在 `failed` 的 else 里', () => {
    // 把 WARN 那段挪进「没 fail 才提」是个很自然的退化（看起来「红了就不用再提免责」），
    // 而 C2 与 A 组都抓不到它：C2 只看红档里的 ❌，A 组的 fixture 从不红。
    const r = runGate(fixture({ extraSkill: SKILL_UNREGISTERED }), GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('❌ skills/fx-delta/SKILL.md: 唤醒词未在 WAKE-WORDS.md 注册')
    expect(r.stderr).toContain(WARN_P1)
    expect(r.stderr).toContain(WARN_P2)
    // 汇总行在红档里同样要打 ⇒ 证明它不在 `failed ? … : …` 的绿枝上
    expect(r.stdout).toContain(WARN_TALLY)
  })
})
