// [[INFRA-F138]] —— `scripts/audit-prop-naming.mjs` 的**整脚本**回归面。
// -----------------------------------------------------------------------------
// 与 `tests/audit-prop-naming.test.ts` 的分工（⛔ 别合并，两者测的不是一回事）：
//   · 那一份 `import { findViolations }` 直接调判据函数，并**自带一份 SoT 副本**
//     （文件里那个 `const SOT = {...}` 注释写着「mirrors prop-naming-conventions.json」）
//     ⇒ 它覆盖判据逻辑，但**结构上看不见**：入口守卫 / 配置文件怎么被读进来 /
//     真 SoT 怎么被读进来 / 判据 → 输出 → 退出码这一整条接线。它连 `main` 都没导入。
//   · 本份 **spawn 整个脚本**（共享 harness `tests/lib/gate-fixture-root.ts`）⇒ 覆盖上述整条。
//     被测闸一行不改。
//
// 为什么选它（**爆炸半径**，⛔ 不是「挂载最深」也不是「最便宜」—— 它只 2 层
//   L4 `.husky/pre-commit:374-378` 条件块 + L5 `gate-chain`）。四条都可核：
//   ① **它守的对象随包，而它自己不随包** —— 它校 `src/web-components/components.config.ts`
//      里的**公开 prop 名**，那批名字经 `dist-wc` 的 CE 定义与生成的 React bindings
//      发给所有 consumer ⇒ 假绿代价落在**下游产品写在源码里的 prop 名**（改名即 breaking）。
//      同型先例 = 第五十 / 五十九 / 六十三轮的 consumer-contract / exports-types-contract /
//      layout-tokens，也与棘轮自印那条排序原则同向。
//   ② **本仓没有第二条闸比对命名约定** —— 2026-08-28 独立实测：全仓（排 node_modules /
//      历轮 worktree）读 `prop-naming-conventions.json` 的只有**它自己 + 它那份旧面测试 +
//      文档/spec**，零第二条闸。读同一份 `components.config.ts` 的另有 7 条闸，但它们校的是
//      出口 / 类型 / slot / parity / 版本新鲜度，**没有一条校名字**。
//   ③ 🔴 **它是同轮 6 条 2 层候选里唯一进 `gate-chain` 的**（`package.json` 那条 chain 字符串
//      里逐字含它的 npm key）。其余 5 条（artifact-routing / demo-framework-parity /
//      handoff-deliverable-sections / sort-tokens / rule-load-map）的头注释**逐字写着
//      「⛔ 不在 prepublishOnly 链」**，只在 pr-checks 的「L4-only 闸接线」批次里。
//      ⇒ 本闸的假绿是在**发布前那一关**放行的。
//   ④ 🔴 **它的挂载事实没有任何自声明可比对** —— 它在 `gate-mount-declaration` 的
//      `KNOWN_SILENT` 具名豁免表里（2026-08-28 实测在表内），即头注释无 D1/D2 声明。
//      ⇒ 假绿时连「它本该挂在哪」都只存在于量具的扫描结果里，没有第二家留痕。
//      ⛔ **本面不给它补声明** —— 那是改闸（[[INFRA-F138]] 逐字封的路），且会让那条
//      shrink-only 豁免失效、连带要求改另一条闸的表。如实登记，报 owner。
//   ⚠️ **比 layout-tokens 窄的地方如实说**：它只对**新增 / 改名**的 prop 有作用面
//      （已有的名字早已合规、实测 37 组件 0 违例），且有 owner 人工 review 兜一层。
//
// 🔴 **harness 的 `realpathSync` 对本闸是承重的。** 本闸用**原始**入口守卫
//   （`` import.meta.url === `file://${process.argv[1]}` ``），属 [[INFRA-F138]] 记的
//   「14 条非随包闸仍用原始守卫」那一档（⛔ 那不是缺陷 —— 它们不随包、只从仓库根跑）。
//   独立探针实测（同一份含三条真违例的 fixture，只差 root 有没有 realpath）：
//   不 realpath ⇒ **`EXIT=0` + 双流零字节**；realpath ⇒ `EXIT=1` + 299 B stdout。
//
// 🔴 **本轮给 [[INFRA-F138]] 论点补的形态：两条流的方向是互换的 ⇒ 「stderr 非空 = 有问题」
//   在这条闸上是反的。** 本闸**判据红全走 stdout、stderr 零字节**（`console.log` +
//   `process.exit(1)`），而**崩溃**才 stderr 非空、stdout 零字节。⇒ 任何「grep stderr 判红」
//   或「stdout 有输出就算跑过了」的检测，在它面前会把红读成绿、把崩溃读成红。
//   前几轮撞的是「退出码变输出不变」「输出变退出码不变」「退出码对而红的原因全变」，
//   这是第四种：**红与崩溃各自占一条流，且和直觉相反**。K 组按流分别钉住。
//
// 🔴 **本轮如实登记两处缺陷，⛔ 本面一律钉「现行行为」而非背书；改闸是 entry 封的路 ⇒ 报 owner：**
//   **(a) 没有任何分母 fail-closed 出口。** `COMPONENT_CONFIGS` 为空数组 ⇒ 闸印
//      `0 components scanned` + `OK — 所有公开 prop 名符合命名规范。` + **`EXIT=0`**。
//      ⇒ 「配置被清空 / 解析出 0 个组件」与「37 个组件全合规」在**退出码上完全同形**。
//      与 [[INFRA-F138]] 已登记的 mockup-library-binding「绿档没有任何分母」、
//      product-code 的 `--dir` 空分母是**同族的第三个实例**；⚠️ **不同点要如实说**：本闸
//      至少把分母**印出来了**（那两条连印都没印）⇒ 人眼可见、机械无信号。
//      修法方向 = 分母 0 时 fail closed + 显式逃逸口（与那两条同型），**没有实施**。
//   **(b) `blocklistPatterns` 缺失是静默 fail-open。** 它走 `?? []` ⇒ SoT 里少这个键
//      ⇒ 黑名单**模式**那一整条判据静默消失、闸照印 `OK` + `EXIT=0`（实测：同一个
//      命中模式的 prop 名，有键 ⇒ `EXIT=1`，无键 ⇒ `EXIT=0`）。
//      ⚠️ 而 SoT 另外三个键的缺失行为**三个方向各不相同**（J 组逐条钉）：
//      `blocklist` / `conventions` 缺失 ⇒ **TypeError 崩溃**（无 `??` 兜底）·
//      `sizeCasing` 缺失 ⇒ `sizeAllowed` 空集 ⇒ **所有 size 值全报违例**（fail-loud）。
//      ⇒ 一份 SoT、四个键、三种畸形口径，没有一处是显式判据。
//
// ⛔ 本面覆盖不到的一支（**如实登记的边界，不是待补 TODO**，与前几轮的 `EXEMPTIONS` 空表 /
//   `STRUCTURAL_L4_ONLY` / `KNOWN_COLLISIONS` 同型）：**`Node < 22` 的 skip 分支**。
//   它读 `process.versions.node`，fixture 侧无法在不改闸、不换 node 二进制的前提下伪造。
//   C 组那条自钉判据把前提钉住（跑本面的 node 必须 ≥ 22），⇒ 前提一破，本面的绿会**变成
//   那条 skip 分支的绿**（`exit 0` + 一句 skipped），本条边界当场转红并把人指到这里。
//
// ⛔ 污染面纪律（[[INFRA-F138]] 逐字）：fixture 假名统一 `bruxo`（落地前实测全仓 0 文件命中，
//   含 30 个历轮 worktree；探针另用 `jelmt`，**刻意不同以免自污染**）。散文里提到别的闸一律
//   **去 `audit-` 前缀、去 `.mjs` 后缀、去 `audit:` 冒号** —— 否则量具会把那条闸误报成
//   「已覆盖」（高报）或挪档（低报）。本闸 stdout 只引用它自己的 SoT 文件名 ⇒ 不需要
//   「断言截短」那一招。
//
// ⚠️ **本闸绿档只有一个 fixture 自己的读数**（`N components scanned`）—— 比 layout-tokens
//   那轮的四个弱。⇒ 非空过凭据按 doc-de-mirror 那轮的既有范式补齐：**绿档钉组件数**（真仓库
//   不可能印 5）+ **红档钉三个来自不同输入面的读数**（组件名 / prop 名来自配置文件 ·
//   `应命名 "bruxokind"` 来自 fixture 的 SoT conventions · `非大写规范子集: …` 来自 fixture 的
//   sizeCasing）+ **红绿配对**（跑错了树的话红档根本红不了）。
// -----------------------------------------------------------------------------
import { describe, it, expect, afterAll } from 'vitest'
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import {
  createGateFixture,
  runGate,
  expectGateRed,
  expectGateGreen,
  cleanupGateFixtures,
  REPO_ROOT,
  type GateRun,
} from './lib/gate-fixture-root'
import { COMPONENT_CONFIGS } from '../src/web-components/components.config'

const GATE = 'scripts/audit-prop-naming.mjs'

// ⚠️ 这两条路径是闸自己**硬编码**的（`audit-prop-naming.mjs` 里没有把它们导出成常量）。
//    ⇒ 这里不是「维护第二份会漂的副本」，而是复刻闸的硬编码；C 组配自钉判据断言两份
//    真文件都在，路径一改本面当场红。
const CFG_FILE = 'src/web-components/components.config.ts'
const SOT_FILE = 'src/design-system/translation/prop-naming-conventions.json'

// ── fixture 自己的读数：**纯 ASCII 手写的设计值**，⛔ 不用被测代码算 ──────────────
const FIX_AXIS_FILL_NAME = 'bruxofill' // fixture SoT 的 conventions.fill
const FIX_AXIS_KIND_NAME = 'bruxokind' // fixture SoT 的 conventions.kind
const FIX_BLOCKED = 'bruxobad' // fixture SoT 的 blocklist 唯一项
const FIX_PATTERN_HIT = 'bruxopat7' // 命中 fixture SoT 的 blocklistPatterns
const FIX_SIZE_OK_A = 'BRUXOS' // fixture SoT 的 sizeCasing.allowed
const FIX_SIZE_OK_B = 'BRUXOL'
const FIX_SIZE_BAD_A = 'bruxos' // 不在 allowed 里 ⇒ size-casing 开火
const FIX_SIZE_BAD_B = 'bruxol'
const FIX_UNCONVENTIONED_AXIS = 'bruxoaxis' // 刻意**不在** fixture conventions 里 ⇒ concept 不该开火

/** fixture 的 SoT —— 四个键都在、值与真仓库无交集（B 组自证）。 */
const FIX_SOT = {
  conventions: { fill: FIX_AXIS_FILL_NAME, kind: FIX_AXIS_KIND_NAME },
  blocklist: [FIX_BLOCKED],
  blocklistPatterns: ['^bruxopat\\d+$'],
  sizeCasing: { allowed: [FIX_SIZE_OK_A, FIX_SIZE_OK_B] },
}

type FixProp = { name: string; axis?: string; tsType?: string }

/** 造 `components.config.ts` 的内容。⚠️ 刻意带 TS 类型注解 —— 钉住类型剥离那条路径真的在走。 */
function configTs(components: Array<{ name: string; props: FixProp[] }>): string {
  return [
    'type BruxoProp = { name: string; axis?: string; tsType?: string }',
    'type BruxoComponent = { name: string; props: BruxoProp[] }',
    `export const COMPONENT_CONFIGS: BruxoComponent[] = ${JSON.stringify(components, null, 2)}`,
    '',
  ].join('\n')
}

/** 健康组件集：5 个组件（真仓库 37）· 三条判据各有一个**合规**样本。 */
const HEALTHY_COMPONENTS: Array<{ name: string; props: FixProp[] }> = [
  { name: 'BruxoAlpha', props: [{ name: FIX_AXIS_FILL_NAME, axis: 'fill', tsType: "'a' | 'b'" }] },
  { name: 'BruxoBeta', props: [{ name: FIX_AXIS_KIND_NAME, axis: 'kind', tsType: "'a' | 'b'" }] },
  { name: 'BruxoGamma', props: [{ name: 'size', tsType: `'${FIX_SIZE_OK_A}' | '${FIX_SIZE_OK_B}'` }] },
  { name: 'BruxoDelta', props: [{ name: 'label', tsType: 'string' }] },
  { name: 'BruxoEpsilon', props: [] }, // 无 props ⇒ 钉住 `c.props ?? []` 那条兜底
]
const FIX_COMPONENT_COUNT = HEALTHY_COMPONENTS.length // 5 —— 绿档唯一的 fixture 自有读数

const HEALTHY: Record<string, string> = {
  [CFG_FILE]: configTs(HEALTHY_COMPONENTS),
  [SOT_FILE]: JSON.stringify(FIX_SOT, null, 2),
}

/**
 * 建 fixture + 跑整条闸。
 * `components` 覆盖组件集，`sot` 覆盖 SoT（传 `string` = 原样写入，用于造畸形 JSON），
 * `drop` 删掉某份输入。
 */
function run(
  prefix: string,
  opts: {
    components?: Array<{ name: string; props: FixProp[] }>
    sot?: object | string
    drop?: string[]
  } = {},
): GateRun {
  const files: Record<string, string> = { ...HEALTHY }
  if (opts.components) files[CFG_FILE] = configTs(opts.components)
  if (opts.sot !== undefined) {
    files[SOT_FILE] = typeof opts.sot === 'string' ? opts.sot : JSON.stringify(opts.sot, null, 2)
  }
  for (const d of opts.drop ?? []) delete files[d]
  const root = createGateFixture({
    gate: GATE,
    prefix,
    files,
    // ⛔ 显式关掉 harness 默认的 `scripts/lib` 软链 —— 本闸只 import node 内建，不碰它。
    linkDirs: [],
  })
  return runGate(root, GATE)
}

/** 取「程序自己最后那句话」—— 终态事实，见 K 组。 */
function lastLine(s: string): string {
  const lines = s.replace(/\s+$/, '').split('\n')
  return lines[lines.length - 1]
}

// ── 真仓库读数：⛔ **不写死**，当场从活源取，否则真仓库一改反向钉就静默变空 ──────────
const REAL_SOT = JSON.parse(readFileSync(resolve(REPO_ROOT, SOT_FILE), 'utf-8')) as {
  conventions: Record<string, string>
  blocklist: string[]
  blocklistPatterns: string[]
  sizeCasing: { allowed: string[] }
}
const REAL_COMPONENT_COUNT: number = COMPONENT_CONFIGS.length
const REAL_COMPONENT_NAMES: string[] = COMPONENT_CONFIGS.map((c) => c.name)

afterAll(cleanupGateFixtures)

// =============================================================================
describe('A 绿档 —— 非空过凭据 + 常驻负向对照', () => {
  it('健康 fixture 绿，且自印的分母是 fixture 自己的组件数', () => {
    const r = run('bruxo-healthy')
    expectGateGreen(r, {
      contains: [
        // 输入面①：**配置文件的内容**（经 TS 类型剥离动态 import 出来的数组长度）
        `audit-prop-naming: ${FIX_COMPONENT_COUNT} components scanned`,
        'OK — 所有公开 prop 名符合命名规范。',
      ],
    })
  })

  it('绿档是真绿：stderr 零字节、stdout 无任何违例标记（must-not-hit 配正向锚点）', () => {
    const r = run('bruxo-healthy-neg')
    expect(r.status).toBe(0)
    expect(r.stderr).toBe('')
    expect(r.stdout).not.toContain('违例')
    expect(r.stdout).not.toContain('[blocklist]')
    expect(r.stdout).not.toContain('[concept]')
    expect(r.stdout).not.toContain('[size-casing]')
    expect(r.stdout).not.toContain('Fix:')
    // 正向锚点：闸确实跑到了最后一句（否则「压根没跑」也满足上面全部 must-not-hit）
    expect(r.stdout).toContain('OK — 所有公开 prop 名符合命名规范。')
  })

  it('健康档里三条判据各有一个合规样本 ⇒ 绿不是「判据一条都没碰到」', () => {
    // 这条钉的是 fixture 的设计意图：合规样本必须真的走进每条判据的 if 里再落空。
    const withAxis = HEALTHY_COMPONENTS.flatMap((c) => c.props).filter((p) => p.axis)
    const withSize = HEALTHY_COMPONENTS.flatMap((c) => c.props).filter(
      (p) => p.name === 'size' && p.tsType?.includes('|'),
    )
    expect(withAxis.length).toBeGreaterThan(0) // concept 判据的入口条件
    expect(withSize.length).toBeGreaterThan(0) // size-casing 判据的入口条件
    // blocklist 判据对每个 prop 都跑 ⇒ 只要有 prop 就进过
    expect(HEALTHY_COMPONENTS.flatMap((c) => c.props).length).toBeGreaterThan(0)
  })

  it('`props` 缺失的组件不崩（`c.props ?? []` 那条兜底）', () => {
    const r = run('bruxo-noprops', {
      components: [{ name: 'BruxoLone' } as { name: string; props: FixProp[] }],
    })
    expectGateGreen(r, { contains: ['audit-prop-naming: 1 components scanned'] })
  })
})

// =============================================================================
describe('B 反向钉 —— 绿档没有退回真仓库读', () => {
  it('反向钉本身是有意义的（fail-closed 自证：fixture 与真仓库的名值全无交集，且真值非空）', () => {
    // ⛔ 若哪天真仓库的组件数 / 名字 / SoT 值恰好撞上 fixture 的设计值，下面两条反向钉会
    //    静默失去意义 ⇒ 本条先把前提钉住。
    expect(REAL_COMPONENT_COUNT).not.toBe(FIX_COMPONENT_COUNT)
    for (const n of REAL_COMPONENT_NAMES) expect(n.toLowerCase().includes('bruxo')).toBe(false)
    for (const v of Object.values(REAL_SOT.conventions)) {
      expect([FIX_AXIS_FILL_NAME, FIX_AXIS_KIND_NAME]).not.toContain(v)
    }
    expect(REAL_SOT.blocklist).not.toContain(FIX_BLOCKED)
    for (const a of REAL_SOT.sizeCasing.allowed) {
      expect([FIX_SIZE_OK_A, FIX_SIZE_OK_B]).not.toContain(a)
    }
    // 分母非空：三个真值都得真的量到了东西（否则「无交集」是因为真侧是空的）
    expect(REAL_COMPONENT_COUNT).toBeGreaterThan(0)
    expect(Object.keys(REAL_SOT.conventions).length).toBeGreaterThan(0)
    expect(REAL_SOT.blocklist.length).toBeGreaterThan(0)
    expect(REAL_SOT.sizeCasing.allowed.length).toBeGreaterThan(0)
  })

  it('stdout 不含真仓库的组件数、任何真组件名、或真 SoT 的任何约定值', () => {
    const r = run('bruxo-reverse')
    expect(r.stdout).not.toContain(`${REAL_COMPONENT_COUNT} components scanned`)
    for (const n of REAL_COMPONENT_NAMES) expect(r.stdout).not.toContain(n)
    // 正向锚点（must-not-hit 不许空过）：fixture 自己的分母确实印出来了
    expect(r.stdout).toContain(`${FIX_COMPONENT_COUNT} components scanned`)
  })

  it('红档同样不含真仓库读数（跑错了树的话红档根本红不了 —— 红绿配对）', () => {
    const r = run('bruxo-reverse-red', {
      components: [{ name: 'BruxoBad', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expect(r.status).toBe(1)
    expect(r.stdout).not.toContain(`${REAL_COMPONENT_COUNT} components scanned`)
    for (const n of REAL_COMPONENT_NAMES) expect(r.stdout).not.toContain(n)
    // 正向锚点：红的原因是 fixture 自己的 blocklist 项
    expect(r.stdout).toContain(`[blocklist] BruxoBad.${FIX_BLOCKED}`)
  })
})

// =============================================================================
describe('C 自钉判据 —— 本面的覆盖边界靠的是活源的哪几个事实', () => {
  it('闸硬编码的两条输入路径在真仓库里都存在（路径一改本面当场红）', () => {
    expect(() => readFileSync(resolve(REPO_ROOT, SOT_FILE), 'utf-8')).not.toThrow()
    expect(() => readFileSync(resolve(REPO_ROOT, CFG_FILE), 'utf-8')).not.toThrow()
  })

  it('🔴 跑本面的 node 必须 ≥ 22 —— 否则本面的绿是「skip 分支的绿」', () => {
    // 这是文件头那条「Node < 22 skip 分支覆盖不到」的**凭据**，不是一句声称。
    // 前提一破（CI 换到 Node 20 腿上跑 vitest），本条转红并把人指到文件头那段。
    const major = parseInt(process.versions.node.split('.')[0], 10)
    expect(major).toBeGreaterThanOrEqual(22)
  })

  it('🔴 现行行为：没有分母 fail-closed —— 0 个组件照样绿（⛔ 钉住，不是背书）', () => {
    // 文件头 (a) 的凭据。修法方向 = 分母 0 时 fail closed + 显式逃逸口，**本轮未实施**
    // （改闸是 [[INFRA-F138]] 逐字封的路）⇒ 报 owner。
    // 谁哪天补上了 fail-closed，本条会红，并由这段注释说出原因。
    const r = run('bruxo-empty-denominator', { components: [] })
    expect(r.status).toBe(0)
    expect(r.stdout).toContain('audit-prop-naming: 0 components scanned')
    expect(r.stdout).toContain('OK — 所有公开 prop 名符合命名规范。')
    // 与「全合规」在退出码上完全同形 —— 这正是该登记的那件事
    const healthy = run('bruxo-empty-denominator-cmp')
    expect(healthy.status).toBe(r.status)
  })

  it('真 SoT 的四个键都在（⇒ fixture 造的是真形态，J 组的畸形档才有对照意义）', () => {
    expect(Object.keys(REAL_SOT.conventions).length).toBeGreaterThan(0)
    expect(Array.isArray(REAL_SOT.blocklist)).toBe(true)
    expect(Array.isArray(REAL_SOT.blocklistPatterns)).toBe(true)
    expect(Array.isArray(REAL_SOT.sizeCasing?.allowed)).toBe(true)
  })

  it('fixture 的 concept 反例 axis 确实不在自己的 conventions 里（否则 G3 的绿是假绿）', () => {
    expect(Object.keys(FIX_SOT.conventions)).not.toContain(FIX_UNCONVENTIONED_AXIS)
    // 且它也不在真仓库的 conventions 里 —— 否则一旦闸退回真仓库读，G3 仍会绿
    expect(Object.keys(REAL_SOT.conventions)).not.toContain(FIX_UNCONVENTIONED_AXIS)
  })

  it('fixture 的坏 size 值确实不在自己的 allowed 集合里（否则 H 组的红是假红）', () => {
    expect(FIX_SOT.sizeCasing.allowed).not.toContain(FIX_SIZE_BAD_A)
    expect(FIX_SOT.sizeCasing.allowed).not.toContain(FIX_SIZE_BAD_B)
  })
})

// =============================================================================
describe('D main() 独有的接线 —— 旧面结构上覆盖不到（它连 main 都没导入）', () => {
  it('分母自印随配置内容变（钉住它真的在数，不是印死值）', () => {
    const r = run('bruxo-denominator-varies', {
      components: [...HEALTHY_COMPONENTS, { name: 'BruxoZeta', props: [{ name: 'label', tsType: 'string' }] }],
    })
    expect(r.stdout).toContain(`audit-prop-naming: ${FIX_COMPONENT_COUNT + 1} components scanned`)
  })

  it('有真违例时退出码必须是 1（摘掉 process.exit(1) 这一档由本条抓）', () => {
    const r = run('bruxo-exit-wired', {
      components: [{ name: 'BruxoBad', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expect(r.status).toBe(1)
  })

  it('违例数自印，且与逐条明细的行数一致', () => {
    const r = run('bruxo-count-matches', {
      components: [
        { name: 'BruxoA', props: [{ name: FIX_BLOCKED, tsType: 'string' }] },
        { name: 'BruxoB', props: [{ name: FIX_PATTERN_HIT, tsType: 'string' }] },
      ],
    })
    expect(r.stdout).toContain('2 违例：')
    const detailLines = r.stdout.split('\n').filter((l) => /^\s+\[[a-z-]+\]/.test(l))
    expect(detailLines).toHaveLength(2)
  })

  it('红档时分母那行照常印完（判据红 ≠ 程序半途死掉）', () => {
    const r = run('bruxo-red-still-prints', {
      components: [{ name: 'BruxoBad', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expect(r.stdout).toContain('audit-prop-naming: 1 components scanned')
    expect(r.stdout).toContain('Fix: 按 prop-naming-conventions.json 改名 / 打正确 axis。')
    // 而绿档专有的结论句**不该**出现
    expect(r.stdout).not.toContain('OK — 所有公开 prop 名符合命名规范。')
  })

  it('🔴 红档 stderr 零字节 —— 判据红全走 stdout（文件头那条「两条流方向互换」）', () => {
    const r = run('bruxo-red-stderr-empty', {
      components: [{ name: 'BruxoBad', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expect(r.status).toBe(1)
    expect(r.stderr).toBe('')
    // 正向锚点：它真的红了，不是没跑
    expect(r.stdout).toContain('1 违例：')
  })
})

// =============================================================================
describe('E 两条输入面怎么被读进来 —— 含 TS 类型剥离这条路径', () => {
  it('配置文件带 TS 类型注解与 type 别名也能读到（钉住类型剥离在走）', () => {
    // configTs() 生成的内容本身就带两个 type 别名 + 一处 `: BruxoComponent[]` 注解。
    // 若哪天这条路径断了（node 降级 / 闸改成读 .js），本条与 A1 会一起红。
    const r = run('bruxo-ts-stripping')
    expect(r.stdout).toContain(`${FIX_COMPONENT_COUNT} components scanned`)
    expect(HEALTHY[CFG_FILE]).toContain('type BruxoComponent =')
    expect(HEALTHY[CFG_FILE]).toContain(': BruxoComponent[] =')
  })

  it('SoT 文件缺失 → 崩溃，且 ENOENT 路径指向 fixture（无 fallback 回真仓库）', () => {
    const r = run('bruxo-no-sot', { drop: [SOT_FILE] })
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('ENOENT')
    expect(r.stderr).toContain(SOT_FILE)
    // ⛔ 关键：路径不指向真仓库
    expect(r.stderr).not.toContain(resolve(REPO_ROOT, SOT_FILE))
    // 这条走的是崩溃、不是判据 ⇒ stdout 零字节（与 D5 的红档恰好相反）
    expect(r.stdout).toBe('')
  })

  it('配置文件缺失 → ERR_MODULE_NOT_FOUND，且 stdout 零字节', () => {
    const r = run('bruxo-no-config', { drop: [CFG_FILE] })
    expect(r.status).toBe(1)
    expect(r.stderr).toContain('ERR_MODULE_NOT_FOUND')
    expect(r.stdout).toBe('')
  })

  it('SoT 是畸形 JSON → 崩溃（不是当空表放行）', () => {
    const r = run('bruxo-malformed-sot', { sot: '{ bruxo not json' })
    expect(r.status).toBe(1)
    expect(r.stdout).toBe('')
    // 正向锚点：崩的是 JSON 解析，不是别的
    expect(r.stderr).toMatch(/JSON|Unexpected/)
  })
})

// =============================================================================
describe('F blocklist 判据 —— 经整条接线', () => {
  it('黑名单命中 → 红，点名 kind / 组件.prop / fixture 自己的黑名单项', () => {
    const r = run('bruxo-blocklist', {
      components: [{ name: 'BruxoAlpha', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expectGateRed(r, {
      checks: [
        '1 违例：',
        `[blocklist] BruxoAlpha.${FIX_BLOCKED}`,
        `prop 名 "${FIX_BLOCKED}" 在黑名单/垃圾名模式`,
      ],
    })
  })

  it('blocklistPatterns 命中 → 同样红（走的是 fixture 自己那条正则）', () => {
    const r = run('bruxo-blocklist-pattern', {
      components: [{ name: 'BruxoAlpha', props: [{ name: FIX_PATTERN_HIT, tsType: 'string' }] }],
    })
    expectGateRed(r, { checks: [`[blocklist] BruxoAlpha.${FIX_PATTERN_HIT}`] })
  })

  it('🔴 现行行为：缺 `blocklistPatterns` 键 ⇒ 那条判据静默消失、闸照样绿（⛔ 钉住，不是背书）', () => {
    // 文件头 (b) 的凭据 —— 阴阳对照就在同一条用例里：同一个 prop 名，有键红 / 无键绿。
    const withKey = run('bruxo-pattern-present', {
      components: [{ name: 'BruxoAlpha', props: [{ name: FIX_PATTERN_HIT, tsType: 'string' }] }],
    })
    expect(withKey.status).toBe(1)

    const { blocklistPatterns: _dropped, ...sotWithoutPatterns } = FIX_SOT
    const withoutKey = run('bruxo-pattern-absent', {
      components: [{ name: 'BruxoAlpha', props: [{ name: FIX_PATTERN_HIT, tsType: 'string' }] }],
      sot: sotWithoutPatterns,
    })
    expect(withoutKey.status).toBe(0)
    expect(withoutKey.stdout).toContain('OK — 所有公开 prop 名符合命名规范。')
    // 且它不是「压根没跑」—— 分母照常印出来了
    expect(withoutKey.stdout).toContain('audit-prop-naming: 1 components scanned')
  })
})

// =============================================================================
describe('G concept 判据 —— axis 与 prop 名的对应', () => {
  it('axis 与名字不符 → 红，且期望名取自 fixture 的 SoT（输入面②凭据）', () => {
    const r = run('bruxo-concept', {
      components: [{ name: 'BruxoBeta', props: [{ name: 'wrongname', axis: 'kind', tsType: "'a' | 'b'" }] }],
    })
    expectGateRed(r, {
      checks: [
        '[concept] BruxoBeta.wrongname',
        `axis='kind' 应命名 "${FIX_AXIS_KIND_NAME}"，实为 "wrongname"`,
      ],
    })
    // ⛔ 真仓库那份 conventions 的值不该出现（它对 kind 定的是另一个名字）
    expect(r.stdout).not.toContain(`应命名 "${REAL_SOT.conventions.kind}"`)
  })

  it('axis 不在 conventions 里 ⇒ 不开火（钉住 `sot.conventions[p.axis]` 那个条件）', () => {
    const r = run('bruxo-concept-unknown-axis', {
      components: [
        { name: 'BruxoBeta', props: [{ name: 'whatever', axis: FIX_UNCONVENTIONED_AXIS, tsType: "'a' | 'b'" }] },
      ],
    })
    expect(r.status).toBe(0)
    expect(r.stdout).not.toContain('[concept]')
    // 正向锚点：闸跑完了整条路
    expect(r.stdout).toContain('OK — 所有公开 prop 名符合命名规范。')
  })

  it('axis 与名字相符 ⇒ 不开火（正向对照）', () => {
    const r = run('bruxo-concept-ok', {
      components: [
        { name: 'BruxoAlpha', props: [{ name: FIX_AXIS_FILL_NAME, axis: 'fill', tsType: "'a' | 'b'" }] },
      ],
    })
    expect(r.status).toBe(0)
    expect(r.stdout).not.toContain('[concept]')
    expect(r.stdout).toContain('audit-prop-naming: 1 components scanned')
  })

  it('一个 prop 可以同时命中 blocklist 与 concept ⇒ 报两条（不是二选一）', () => {
    const r = run('bruxo-concept-and-blocklist', {
      components: [{ name: 'BruxoAlpha', props: [{ name: FIX_BLOCKED, axis: 'kind', tsType: "'a' | 'b'" }] }],
    })
    expectGateRed(r, {
      checks: ['2 违例：', `[blocklist] BruxoAlpha.${FIX_BLOCKED}`, `[concept] BruxoAlpha.${FIX_BLOCKED}`],
    })
  })
})

// =============================================================================
describe('H size-casing 判据 —— 三个入口条件各钉一条', () => {
  it('坏值 → 红，且逐个列出坏值（取自 fixture 的 tsType）', () => {
    const r = run('bruxo-size-bad', {
      components: [
        { name: 'BruxoGamma', props: [{ name: 'size', tsType: `'${FIX_SIZE_BAD_A}' | '${FIX_SIZE_BAD_B}'` }] },
      ],
    })
    expectGateRed(r, {
      checks: [
        '[size-casing] BruxoGamma.size',
        `size 值非大写规范子集: ${FIX_SIZE_BAD_A}, ${FIX_SIZE_BAD_B}`,
      ],
    })
  })

  it('只有一个坏值时只列它（钉住 filter 真的在筛，不是全量回显）', () => {
    const r = run('bruxo-size-partial', {
      components: [
        { name: 'BruxoGamma', props: [{ name: 'size', tsType: `'${FIX_SIZE_OK_A}' | '${FIX_SIZE_BAD_B}'` }] },
      ],
    })
    expectGateRed(r, { checks: [`size 值非大写规范子集: ${FIX_SIZE_BAD_B}`] })
    // 合规的那个不该被列进坏值里
    expect(r.stdout).not.toContain(`子集: ${FIX_SIZE_OK_A}`)
  })

  it('prop 名不是 `size` ⇒ 不开火（即使值全小写）', () => {
    const r = run('bruxo-size-othername', {
      components: [
        { name: 'BruxoGamma', props: [{ name: 'label', tsType: `'${FIX_SIZE_BAD_A}' | '${FIX_SIZE_BAD_B}'` }] },
      ],
    })
    expect(r.status).toBe(0)
    expect(r.stdout).not.toContain('[size-casing]')
    expect(r.stdout).toContain('OK — 所有公开 prop 名符合命名规范。')
  })

  it('`tsType` 不含 `|` ⇒ 不开火（钉住 `.includes("|")` 那个条件）', () => {
    const r = run('bruxo-size-nounion', {
      components: [{ name: 'BruxoGamma', props: [{ name: 'size', tsType: FIX_SIZE_BAD_A }] }],
    })
    expect(r.status).toBe(0)
    expect(r.stdout).not.toContain('[size-casing]')
    expect(r.stdout).toContain('audit-prop-naming: 1 components scanned')
  })

  it('`tsType` 缺失 ⇒ 不开火（钉住 `typeof p.tsType === "string"` 那个条件）', () => {
    const r = run('bruxo-size-notype', {
      components: [{ name: 'BruxoGamma', props: [{ name: 'size' }] }],
    })
    expect(r.status).toBe(0)
    expect(r.stdout).not.toContain('[size-casing]')
    expect(r.stdout).toContain('audit-prop-naming: 1 components scanned')
  })
})

// =============================================================================
describe('J SoT 四个键的畸形口径 —— 三个方向各不相同（如实登记，⛔ 不是背书）', () => {
  it('缺 `blocklist` ⇒ TypeError 崩溃（无 `??` 兜底），stdout 零字节', () => {
    const { blocklist: _dropped, ...sot } = FIX_SOT
    const r = run('bruxo-sot-no-blocklist', {
      components: [{ name: 'BruxoAlpha', props: [{ name: 'label', tsType: 'string' }] }],
      sot,
    })
    expect(r.status).toBe(1)
    expect(r.stdout).toBe('')
    expect(r.stderr).toContain('TypeError')
  })

  it('缺 `conventions` + 带 axis 的 prop ⇒ TypeError 崩溃', () => {
    const { conventions: _dropped, ...sot } = FIX_SOT
    const r = run('bruxo-sot-no-conventions', {
      components: [{ name: 'BruxoAlpha', props: [{ name: 'whatever', axis: 'fill', tsType: "'a' | 'b'" }] }],
      sot,
    })
    expect(r.status).toBe(1)
    expect(r.stdout).toBe('')
    expect(r.stderr).toContain('TypeError')
  })

  it('缺 `sizeCasing` ⇒ 方向相反：allowed 变空集 ⇒ 合规的 size 值也全报违例（fail-loud）', () => {
    const { sizeCasing: _dropped, ...sot } = FIX_SOT
    const r = run('bruxo-sot-no-sizecasing', {
      components: [
        { name: 'BruxoGamma', props: [{ name: 'size', tsType: `'${FIX_SIZE_OK_A}' | '${FIX_SIZE_OK_B}'` }] },
      ],
      sot,
    })
    expectGateRed(r, {
      checks: [`size 值非大写规范子集: ${FIX_SIZE_OK_A}, ${FIX_SIZE_OK_B}`],
    })
    // 阴性对照：同一份组件集在**有** sizeCasing 时是绿的 ⇒ 差异确实来自那个键
    const withKey = run('bruxo-sot-sizecasing-cmp', {
      components: [
        { name: 'BruxoGamma', props: [{ name: 'size', tsType: `'${FIX_SIZE_OK_A}' | '${FIX_SIZE_OK_B}'` }] },
      ],
    })
    expect(withKey.status).toBe(0)
  })
})

// =============================================================================
describe('K 终态事实 —— 两条流的方向是互换的，按流分别钉', () => {
  // 判据 = **闸自己的最后一句话必须是对应那条流的最后一行**。
  // 崩溃同样是非零退出码，只断言 status 的红是假红（harness 头注释判据 2 逐字预言过）。
  it('绿档：stdout 末行恰好是闸的收尾句，stderr 空', () => {
    const r = run('bruxo-terminal-green')
    expect(lastLine(r.stdout)).toBe('OK — 所有公开 prop 名符合命名规范。')
    expect(r.stderr).toBe('')
  })

  it('红档：stdout 末行是闸自己的 Fix 行（不是崩溃栈），stderr 空', () => {
    const r = run('bruxo-terminal-red', {
      components: [{ name: 'BruxoBad', props: [{ name: FIX_BLOCKED, tsType: 'string' }] }],
    })
    expect(r.status).toBe(1)
    expect(lastLine(r.stdout)).toBe('Fix: 按 prop-naming-conventions.json 改名 / 打正确 axis。')
    expect(lastLine(r.stdout)).not.toMatch(/^Node\.js v/)
    expect(r.stderr).toBe('')
  })

  it('崩溃档：末行落在 **stderr** 且是崩溃栈尾 ⇒ 上两条的区分力是真的（阴性对照）', () => {
    const r = run('bruxo-terminal-crash', { drop: [SOT_FILE] })
    expect(r.status).toBe(1)
    expect(r.stdout).toBe('')
    expect(lastLine(r.stderr)).toMatch(/^Node\.js v/)
  })
})
