// tests/audit-rule-load-map-cli.test.ts
// -----------------------------------------------------------------------------
// `scripts/audit-rule-load-map.mjs` 的**整脚本回归面**，重点是它那条
// **绿档 WARN 通路**（`parentOnly` ⇒ scoped-load 盲区）。
//
// WHY 这条通路值得单独钉（⛔ 不是「顺手补个测试」）：
//   本闸的 `parentOnly` 警告**不改变退出码** —— 它在 exit 0 的同一趟里打到 stderr，
//   语义逐字是「这次的绿里有盲区：这几条子规则只靠 parent 命中，jump-read parent 主段
//   不必然深入到子段」。⇒ 它是**绿档唯一的免责声明**。这类「闸绿着、但应该 WARN」的
//   判据一旦没人守，退化成静默时**没有任何退出码会变**，谁都不会发现。
//
//   ⚠️ 2026-09-14 之前它确实没人守：共享 harness 的 `runGate` 在绿档硬编码 `stderr: ''`
//   （走 `execFileSync` 的成功分支），闸的 `console.warn` 打到 vitest 控制台却从未进过返回值
//   ⇒ 任何绿档 stderr 断言在这套 harness 上**结构上写不出来**。那行改成 `spawnSync` 之后
//   本面才成立 —— 见 `tests/lib/gate-fixture-root.ts` 里 `runGate` 的 2026-09-14 那段头注释。
//
// ⛔ 断言必须**点名 WARN 的原文**，⛔ 不许写 `expect(stderr).not.toBe('')`：
//   fixture 不是 git 仓时，闸内若有 `execSync git` 会把 `fatal: not a git repository`
//   透传进 stderr ⇒ 「非空」这类断言**恒真**。本闸当前不跑 git，但那条纪律对整族成立。
//
// ⛔ 与本闸判据逻辑的分工：`extractGuideBlock` / `parentOf` / `findUnroutedRules` 三个
//   纯函数是 export 的，逻辑层可以 import 它们测。本份 spawn 整个脚本，覆盖只有整脚本
//   才看得见的三样：
//     · 入口守卫 → `main()` 的接线（本闸用**手写**守卫
//       `import.meta.url === \`file://${process.argv[1]}\``，守卫恒假时是「exit 0 且零输出」
//       —— 与「跑完全绿」逐字同码，只有钉末行分得开）
//     · `console.warn` 那条**通路**本身（纯函数只返回 `parentOnly` 数组，打不打得出去它不知道）
//     · WARN 与 FAIL 并存时**两段都执行**（WARN 那段在 `failed` 分支之后，⛔ 不在 else 里）
//
// ⛔ 污染纪律：本文件除被测闸自身外**不出现任何别的闸的 `.mjs` basename、也不出现任何别的
//   `audit:` npm key** —— 否则按名字扫的量具会把那条闸误报成「已覆盖」。
//   fixture 的规则 id 一律用真仓库不可能撞上的高位号（`M90` / `M90.1` / `C90` / `R90`，
//   2026-09-14 实测真仓库零命中），⛔ 别用 `M32.1` 这类活号。
//
// ⚠️ **fixture 里的 §🤖 AI 读取指引 段是判据的输入，写字要当心**：闸取整段后
//   `split(/[^A-Za-z0-9.\-]+/)` 切 token，**不剥注释、不剥代码块** ⇒ 段内任何位置出现
//   `M90.1` 字面就等于替它登记了路由，WARN 当场消失。这既是 A 组的坑，也正是 B 组
//   对照臂的**开关**（见 `guideWithSubRules`）。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { createGateFixture, runGate, cleanupGateFixtures } from './lib/gate-fixture-root'

const GATE = 'scripts/audit-rule-load-map.mjs'

afterAll(() => cleanupGateFixtures())

// ── fixture 素材 ────────────────────────────────────────────────────────────
// 真仓库现取（2026-09-14 @`4bdee739`）：mockup 76 rules / code 23 rules，
// 自报盲区 4 条（M32.1 / M36.1 / M37.1 / M37.2）。fixture 的数（3 / 1 / 2 条盲区）
// 与它们**全不相同** —— 绿档钉的就是这几个数，闸若退回真仓库读，本组必炸。

/** 路由表（§AI 读取指引 段）。`extraRoutes` 里写什么，就等于替谁登记了路由。 */
function guide(extraRoutes: string[] = []): string {
  return [
    '## 🤖 AI 读取指引',
    '',
    '| 触发场景 | 读哪条 |',
    '|---|---|',
    '| 画组件 | M90 |',
    '| 配色 | M-COLOR |',
    ...extraRoutes,
    '',
  ].join('\n')
}

/**
 * 有盲区的 mockup 文档：3 条规则，其中 2 条是**子规则且只靠 parent 命中**。
 *
 * 两条子规则**刻意走 `parentOf()` 的两条不同分支**（含点取点前 / `C\d+`→`M-COLOR`），
 * 且排序后是 `C90` 在前 —— 于是 stderr 里那两行明细能证明 `for (const p of parentOnly)`
 * 真的迭代了，⛔ 不是只印了第一条。
 */
const MOCKUP_WITH_BLIND_SPOT = [
  '# Fixture Mockup Conventions',
  '',
  guide(),
  '## 规则',
  '',
  '### M90 — fixture umbrella 规则（自身已登记路由）',
  '',
  '正文。',
  '',
  '#### M90.1 — fixture 子规则（触发语义独立，却只靠 parent M90 命中）',
  '',
  '正文。',
  '',
  '### C90 — fixture 颜色子规则（只靠 parent M-COLOR 命中）',
  '',
  '正文。',
  '',
].join('\n')

/** 对照臂用：同一份文档，只把两条子规则**也**写进路由表 ⇒ 盲区归零。 */
const MOCKUP_NO_BLIND_SPOT = MOCKUP_WITH_BLIND_SPOT.replace(
  guide(),
  guide(['| 子场景 | M90.1 |', '| 颜色子场景 | C90 |']),
)

/**
 * 红档用：追加一条 self 与 parent **都**未登记的规则 ⇒ unrouted ⇒ exit 1。
 *
 * 刻意用**子规则**形态（`M91.1`，parent `M91` 同样不在路由表）：它落 `unrouted` 而非
 * `parentOnly`（后者要求 parent 已命中），且明细行会走 `u.parent !== u.id` 那条三元分支
 * ⇒ 顺带钉住「parent 也没命中」这半句。
 */
const MOCKUP_UNROUTED = `${MOCKUP_WITH_BLIND_SPOT}\n#### M91.1 — fixture 未进路由表的子规则\n\n正文。\n`

/** code 文档恒干净：1 条规则、零盲区 ⇒ 它是本文件天然的 must-not-hit（WARN 只该来自 mockup 那份）。 */
const CODE_CLEAN = [
  '# Fixture Code Conventions',
  '',
  '## 🤖 AI 读取指引',
  '',
  '| 触发场景 | 读哪条 |',
  '|---|---|',
  '| 写代码 | R90 |',
  '',
  '## 规则',
  '',
  '### R90 — fixture 代码规则',
  '',
  '正文。',
  '',
].join('\n')

/** 闸的 WARN 抬头（逐字取自 `scripts/audit-rule-load-map.mjs`，含裸 `⚠ ` 与 em dash）。 */
const WARN_HEAD =
  '⚠ docs/internal/mockup-conventions.md: 2 子规则仅靠 parent 覆盖（scoped-load 盲区 — 触发语义独立的请单独登记路由表）:'
const WARN_LINE_C90 = '    - C90  (仅靠 parent M-COLOR)'
const WARN_LINE_M901 = '    - M90.1  (仅靠 parent M90)'

function fixture(mockup: string = MOCKUP_WITH_BLIND_SPOT): string {
  return createGateFixture({
    gate: GATE,
    prefix: 'rule-load-map-fx',
    // ⛔ `linkDirs` 用默认（软链 `scripts/lib`）—— 闸 import `./lib/rule-ids.mjs`，
    //    传 `[]` 会让它 ENOENT 崩掉，而**崩溃也是非零退出** ⇒ 红得理由不对。
    files: {
      'docs/internal/mockup-conventions.md': mockup,
      'docs/internal/code-conventions.md': CODE_CLEAN,
    },
  })
}

describe('A 绿档 WARN 通路（本文件存在的理由：exit 0 的那一趟里，盲区有没有被说出来）', () => {
  it('A1 🔴 有盲区 ⇒ exit 0，且 stderr **逐字**打出 WARN 抬头与两条明细', () => {
    const r = runGate(fixture(), GATE)
    // 绿档：这条通路的语义就是「不拦，只声明」—— 退出码变了反而是回归。
    expect(r.status, `闸的 stderr:\n${r.stderr}\n---stdout---\n${r.stdout}`).toBe(0)
    // ⛔ 点名原文，⛔ 不写 not.toBe('')（见文件头：git 透传会让「非空」恒真）
    expect(r.stderr).toContain(WARN_HEAD)
    // 两条明细都在 ⇒ `for (const p of parentOnly)` 真的迭代了，不是只印第一条
    expect(r.stderr).toContain(WARN_LINE_C90)
    expect(r.stderr).toContain(WARN_LINE_M901)
    // must-not-hit：干净的那份文档不该也被报 —— 证明 WARN 不是「见文件就打」
    expect(r.stderr).not.toContain('code-conventions.md: ')
  })

  it('A2 WARN 走的是 **stderr**，⛔ 不在 stdout —— 换成 console.log 时本条红而 A1 也红', () => {
    const r = runGate(fixture(), GATE)
    expect(r.stdout).not.toContain('子规则仅靠 parent 覆盖')
    expect(r.stdout).not.toContain('仅靠 parent M90')
  })

  it('A3 非空过凭据：自印的分母是 **fixture 自己的**，不是真仓库的', () => {
    const r = runGate(fixture(), GATE)
    expect(r.stdout).toContain('✓ docs/internal/mockup-conventions.md: 3 rules 全部被路由表覆盖')
    expect(r.stdout).toContain('✓ docs/internal/code-conventions.md: 1 rules 全部被路由表覆盖')
    // ⛔ 反向：真仓库读数绝不该出现（闸退回真仓库读 ⇒ 本条必炸）
    expect(r.stdout).not.toContain('76 rules')
    expect(r.stdout).not.toContain('23 rules')
    expect(r.stderr).not.toContain('M32.1')
    expect(r.stderr).not.toContain('M37.2')
  })
})

describe('B 对照臂（⛔ 缺这一组，A 组分不清「真的打了 WARN」与「恒真」）', () => {
  it('B1 🔴 两条子规则也进路由表 ⇒ 仍 exit 0，但 stderr **零字节**', () => {
    const r = runGate(fixture(MOCKUP_NO_BLIND_SPOT), GATE)
    expect(r.status, `闸的 stderr:\n${r.stderr}`).toBe(0)
    // 本闸绿档不跑 git、无其它 stderr 来源 ⇒ 这里是真的零字节
    expect(r.stderr).toBe('')
    // 正向锚点：这一趟确实跑完了整条判据链（⛔ 不是「没执行所以也没 WARN」）
    expect(r.stdout).toContain('✓ docs/internal/mockup-conventions.md: 3 rules 全部被路由表覆盖')
  })

  it('B2 开关只在路由表那一段 —— 规则正文一字未动，盲区就从 2 归 0', () => {
    // A1 与 B1 的两份文档差的**只有**路由表里那两行 ⇒ 证明 WARN 是判据算出来的，
    // ⛔ 不是跟着「文档里有没有子规则」走的。
    const withBlind = runGate(fixture(), GATE)
    const noBlind = runGate(fixture(MOCKUP_NO_BLIND_SPOT), GATE)
    expect(withBlind.stderr).toContain('2 子规则仅靠 parent 覆盖')
    expect(noBlind.stderr).not.toContain('子规则仅靠 parent 覆盖')
    // 两趟的 stdout 逐字相同 —— 差别**只**发生在 stderr 这条流上
    expect(noBlind.stdout).toBe(withBlind.stdout)
  })
})

describe('C 接线（这一层只 import 判据函数结构上看不见）', () => {
  it('C1 🔴 入口守卫真的开火了 —— 恒假时会是「exit 0 且零输出」，与全绿逐字同码', () => {
    // 本闸用手写守卫 `import.meta.url === \`file://${process.argv[1]}\``，
    // 而 macOS 的 tmpdir 在 symlink 下 ⇒ root 不 realpath 时它恒假、main() 整个不执行。
    // 共享 harness 已在 root 上 realpathSync，本条是那行的回归钉。
    // ⛔ 判据取**终态事实**：末行必须是闸自己的收尾句。
    const r = runGate(fixture(MOCKUP_NO_BLIND_SPOT), 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('audit-rule-load-map OK — scoped-load 路由表覆盖完整')
  })

  it('C2 阻断出口接上了：self 与 parent 都未登记 ⇒ exit 1 并点名那条规则', () => {
    const r = runGate(fixture(MOCKUP_UNROUTED), GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain(
      '✗ docs/internal/mockup-conventions.md: 1/4 rule 未进 §AI 读取指引 路由表（scoped-load 会漏）:',
    )
    expect(r.stderr).toContain('    - M91.1  (parent M91 也未命中)')
    // 后半条抓另一种假红：console.error 打完才崩 ⇒ 退出码碰巧也非零、点名也在，
    // 只有「末行是不是闸自己的收尾句」分得开（崩溃时末行是 `Node.js vXX` 栈尾）。
    const errLines = r.stderr.trim().split('\n')
    expect(errLines[errLines.length - 1]).toBe(
      'audit-rule-load-map FAIL — 补全 §AI 读取指引 路由表后重跑',
    )
  })

  it('C3 🔴 FAIL 与 WARN **并存**时两段都执行 —— WARN 那段在 failed 分支之后，⛔ 不在 else 里', () => {
    // 把 WARN 那段挪进 `else`（看起来「红了就不用再提盲区」）是个很自然的退化，
    // 而 C2 与 A 组都抓不到它：C2 只看红档、A 组的 fixture 从不红。
    const r = runGate(fixture(MOCKUP_UNROUTED), GATE)
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('✗ docs/internal/mockup-conventions.md: 1/4 rule 未进')
    expect(r.stderr).toContain('2 子规则仅靠 parent 覆盖')
    expect(r.stderr).toContain(WARN_LINE_M901)
  })
})
