// INFRA-F100: scripts/lib/render-drift-gate-core.mjs 的判据单测。
//
// 落地前**两条 render 闸都没有任何单测**（`grep -rl audit-render-drift-gate tests/` = 空），
// 判据只在真跑 playwright 之后才被行使过。本文件补上，并且每一条判据都配**阴性对照**：
// 先断言未污染的 fixture EXIT=0，再只改一个字段断言 EXIT=1 —— 否则「16/16 全绿」可能是
// fixture 里的附带细节替判据挡住了故障（本仓已实证过这种空过）。
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { runRenderDriftGate } from '../scripts/lib/render-drift-gate-core.mjs'
import { computeRenderInputsFingerprint } from '../scripts/lib/render-inputs-fingerprint.mjs'

let dir: string
/** S9 重算指纹用的假「仓库根」。真树的指纹会随每次 commit 变，测里必须用可控的树。 */
let fpRoot: string

/** 在 fpRoot 下搭一棵最小输入树（`src/` + 那份 manifest），形状与真仓一致。 */
function scaffoldInputs() {
  fs.mkdirSync(path.join(fpRoot, 'src/components'), { recursive: true })
  fs.mkdirSync(path.join(fpRoot, 'figma-data'), { recursive: true })
  fs.writeFileSync(path.join(fpRoot, 'src/index.ts'), 'export const a = 1\n')
  fs.writeFileSync(path.join(fpRoot, 'src/components/Button.vue'), '<template>b</template>\n')
  fs.writeFileSync(path.join(fpRoot, 'figma-data/render-verification-manifest.json'), '[{"id":1}]\n')
}

const EXCUSE_ROWS = [
  { component: 'Tooltips', field: 'rootWidth', entryScope: '*', reason: 'r1', fixDirection: 'f1', reviewBy: '2099-01-01', ceReviewedAt: '2026-08-17', ceNote: 'ce1' },
  { component: 'Table', field: 'rootBorderHex', entryScope: '*', reason: 'r2', fixDirection: 'f2', reviewBy: '2099-01-01', ceReviewedAt: '2026-08-17', ceNote: 'ce2' },
]

/** 一份**会通过全部判据**的 report。每个用例只改它的一个字段。 */
function baseSummary(overrides: Record<string, unknown> = {}) {
  return {
    total: 2,
    pass: 2,
    passByModeSkip: 0,
    fail: 0,
    passRate: 1,
    classifications: { A_TRUE_DRIFT_CANDIDATE: 0, B_RESIDUAL_SCHEMA_GAP: 0, C_BOUNDARY_CASE: 0 },
    navigationFailures: 0,
    measuredEntries: 2,
    excuse: { rows: 2, matched: 2, unmatched: [] as string[], excusedChecks: 0, rowScopes: [] },
    // INFRA-F129 ① S10：健康 report 必须带这个桶。⚠️ 加进 fixture 是**因为 report schema
    // 真的多了一个必填字段**（S10 fail closed），⛔ 不是「为了让测试过而迁就实现」——
    // 缺这个桶时按 0 读恰好读反（旧格式里那些 check 连行都没发出）。
    unverifiableChecks: { total: 0, byField: {} as Record<string, number>, byComponent: {} as Record<string, number> },
    ...overrides,
  }
}

function write(name: string, value: unknown) {
  const p = path.join(dir, name)
  fs.writeFileSync(p, JSON.stringify(value, null, 2))
  return p
}

type Cfg = Partial<Parameters<typeof runRenderDriftGate>[0]>

/** 跑一次闸，返回 exit code + 捕获到的输出。 */
/**
 * 一份「S9 会通过」的指纹字段 —— 现算 fpRoot 那棵树。
 * ⚠️ 刻意**现算**而不是写死 hex：写死的话，任何改动枚举规则的人都会看到一堆莫名的红，
 * 而现算能让「算法真变了」与「输入真变了」分别被 S9 的两条不同文案报出来。
 */
function liveFingerprint() {
  return computeRenderInputsFingerprint(fpRoot)
}

/** 默认 report 骨架（含合法指纹）。⚠️ 每个用例只改它的一个字段。 */
function baseReport(overrides: Record<string, unknown> = {}) {
  return {
    checkedAt: 'X',
    inputsFingerprint: liveFingerprint(),
    summary: baseSummary(),
    nodeCoverage: { mismatches: 0 },
    entries: [] as unknown[],
    ...overrides,
  }
}

function run(cfg: Cfg = {}, files: { self?: unknown; sibling?: unknown; manifest?: unknown; excuse?: unknown } = {}) {
  const out: string[] = []
  const io = { log: (...a: unknown[]) => out.push(String(a[0])), error: (...a: unknown[]) => out.push(String(a[0])) }
  // ⚠️ 各用例传进来的 self/sibling 若没带 inputsFingerprint，就补一份合法的 —— 否则
  //    「S1 用例红了」可能是 S9 抓的、不是 S1 抓的（本文件既有的 fixture 全部早于 S9）。
  const withFp = (v: unknown) =>
    v && typeof v === 'object' && !('inputsFingerprint' in (v as object))
      ? { inputsFingerprint: liveFingerprint(), ...(v as object) }
      : v
  const reportPath = write('self.json', withFp(files.self) ?? baseReport())
  const siblingReportPath = write('sibling.json', withFp(files.sibling) ?? baseReport())
  const manifestPath = write('manifest.json', files.manifest ?? [{ codeComponent: 'Button' }, { codeComponent: 'Tooltip' }])
  const excusePath = write('excuse.json', files.excuse ?? EXCUSE_ROWS)
  const code = runRenderDriftGate(
    {
      label: 'test-gate',
      reportPath,
      manifestPath,
      scopeFilter: (e: unknown[]) => e,
      refreshCommand: 'pnpm test:x',
      baselineA: 0,
      baselineNodeMismatch: 0,
      excusePath,
      siblingReportPath,
      siblingRefreshCommand: 'pnpm test:y',
      coverageNote: 'note',
      fingerprintRoot: fpRoot,
      ...cfg,
    } as Parameters<typeof runRenderDriftGate>[0],
    io,
  )
  return { code, out: out.join('\n') }
}

beforeEach(() => {
  dir = fs.mkdtempSync(path.join(os.tmpdir(), 'f100-gate-'))
  fpRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'f99-root-'))
  scaffoldInputs()
})
afterEach(() => {
  fs.rmSync(dir, { recursive: true, force: true })
  fs.rmSync(fpRoot, { recursive: true, force: true })
})

describe('render-drift-gate-core', () => {
  // ⚠️ 阴性对照 —— 后面每一条 FAIL 用例都是在这份 fixture 上只改一个字段。
  // 它若不为 0，下面的红全都不能证明是判据抓到的。
  it('未污染的 fixture 全绿（阴性对照）', () => {
    const r = run()
    expect(r.out).toContain('PASS')
    expect(r.code).toBe(0)
  })

  it('S1：total <= 0 → 红', () => {
    const r = run({}, { self: { summary: baseSummary({ total: 0, measuredEntries: 0 }), nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S1)')
  })

  it('S2：report 条数 ≠ 磁盘 manifest 过滤后条数 → 红', () => {
    const r = run({}, { manifest: [{ codeComponent: 'Button' }, { codeComponent: 'Tooltip' }, { codeComponent: 'Table' }] })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S2)')
  })

  it('S2：scopeFilter 参与判据 —— 同一份 manifest，过滤面对得上就绿、对不上就红', () => {
    const manifest = [{ codeComponent: 'Button' }, { codeComponent: 'Tooltip' }, { codeComponent: 'Table' }]
    const keepTwo = (e: any[]) => e.filter((x) => x.codeComponent !== 'Table')
    expect(run({ scopeFilter: keepTwo }, { manifest }).code).toBe(0)
    expect(run({ scopeFilter: (e: any[]) => e }, { manifest }).code).toBe(1)
  })

  it('S2：scopeFilter 抛异常 → fail closed（⛔ 不当空集）', () => {
    const r = run({ scopeFilter: () => { throw new TypeError('boom') } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('覆盖面过滤失败')
  })

  it('S3：navigationFailures ≠ 0 → 红（INFRA-F97 本体：服务器死掉那一类跑）', () => {
    const r = run({}, {
      self: {
        summary: baseSummary({ navigationFailures: 2, measuredEntries: 2 }),
        nodeCoverage: { mismatches: 0 },
        entries: [{ figmaName: 'Button', manifestId: 'b1', checks: [{ field: 'navigation', pass: false, actual: 'ERR_CONNECTION_REFUSED' }] }],
      },
    })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S3)')
    expect(r.out).toContain('ERR_CONNECTION_REFUSED')
  })

  it('S4：measuredEntries < total → 红（导航成功但一条都没量到）', () => {
    const r = run({}, { self: { summary: baseSummary({ measuredEntries: 1 }), nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S4)')
  })

  it('A 上界：A > baseline → 红并点名', () => {
    const r = run({}, {
      self: {
        summary: baseSummary({ classifications: { A_TRUE_DRIFT_CANDIDATE: 1, B_RESIDUAL_SCHEMA_GAP: 0, C_BOUNDARY_CASE: 0 } }),
        nodeCoverage: { mismatches: 0 },
        entries: [{ figmaName: 'Slider', manifestId: 's1', checks: [{ field: 'rootWidth', classification: 'A_TRUE_DRIFT_CANDIDATE', expected: 240, actual: 300 }] }],
      },
    })
    expect(r.code).toBe(1)
    expect(r.out).toContain('A_TRUE_DRIFT_CANDIDATE=1 > baseline 0')
    expect(r.out).toContain('Slider')
  })

  it('node mismatch 上界 → 红', () => {
    const r = run({}, {
      self: {
        summary: baseSummary(),
        nodeCoverage: { mismatches: 1 },
        entries: [{ figmaName: 'Badge', manifestId: 'b1', nodeChecks: [{ status: 'mismatch', figmaNodeId: '1:2', name: 'bg', checks: [{ pass: false, prop: 'fill', expected: '#000', actual: '#fff' }] }] }],
      },
    })
    expect(r.code).toBe(1)
    expect(r.out).toContain('full-tree node mismatches=1')
  })

  it('测量量字段缺失 → fail closed（⛔ 不当 0 处理）', () => {
    const s = baseSummary() as Record<string, unknown>
    delete s.measuredEntries
    const r = run({}, { self: { summary: s, nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('summary.measuredEntries')
  })

  it('S5：summary.excuse 缺失 → fail closed', () => {
    const s = baseSummary() as Record<string, unknown>
    delete s.excuse
    const r = run({}, { self: { summary: s, nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S5)')
  })

  // ── S10（INFRA-F129 ①）：`unverifiable` 桶的形状 fail-closed ─────────────────
  // ⚠️ 判据只有「形状」这一条 —— **刻意没有数字上/下界**：那个棘轮在
  // `pnpm audit:render-silent-checks`（per-component shrink-only），在这里再判一次
  // 就是「同一件事红两次」。下面第三条阴性对照就是钉这一点的。
  it('S10：summary.unverifiableChecks 缺失 → fail closed（旧格式 report 不许冒充「0 条未验」）', () => {
    const s = baseSummary() as Record<string, unknown>
    delete s.unverifiableChecks
    const r = run({}, { self: { summary: s, nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S10)')
  })

  it('S10：形状不对（byField 不是对象 / total 不是数字）→ fail closed', () => {
    for (const bad of [
      { total: '284', byField: {}, byComponent: {} },
      { total: 284, byField: null, byComponent: {} },
      { total: 284, byField: {}, byComponent: 'nope' },
    ]) {
      const r = run({}, { self: { summary: baseSummary({ unverifiableChecks: bad }), nodeCoverage: { mismatches: 0 }, entries: [] } })
      expect(r.code, JSON.stringify(bad)).toBe(1)
      expect(r.out, JSON.stringify(bad)).toContain('FAIL (S10)')
    }
  })

  it('S10 阴性对照：数字**多大都不红**（数字棘轮在 audit:render-silent-checks，别在这里判两次）', () => {
    const r = run({}, {
      self: {
        summary: baseSummary({
          unverifiableChecks: { total: 99999, byField: { rootRadius: 99999 }, byComponent: { Switch: 99999 } },
        }),
        nodeCoverage: { mismatches: 0 },
        entries: [],
      },
    })
    expect(r.code).toBe(0)
    // 但必须**自印**出来 —— 判据不判它，人得看得见。
    expect(r.out).toContain('S10')
    expect(r.out).toContain('99999')
    expect(r.out).toContain('rootRadius=99999')
  })

  it('S6b：report 自报豁免行数 ≠ 磁盘表行数 → 红', () => {
    const r = run({}, { self: { summary: baseSummary({ excuse: { rows: 99, matched: 2, unmatched: [], excusedChecks: 0, rowScopes: [] } }), nodeCoverage: { mismatches: 0 }, entries: [] } })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S6b)')
  })

  it('S7：reviewBy 过期 → 红', () => {
    const r = run({}, { excuse: [{ ...EXCUSE_ROWS[0], reviewBy: '2000-01-01' }, EXCUSE_ROWS[1]] })
    expect(r.code).toBe(1)
    expect(r.out).toContain('FAIL (S7)')
  })

  // ── S6 跨链 shrink-only —— 这是 INFRA-F100 缺口③ 的判据本体 ────────────────
  describe('S6 跨链 shrink-only', () => {
    const ROW = 'Table | rootBorderHex | *'

    it('两条链都不命中 → 判 stale、红、点名', () => {
      const un = { rows: 2, matched: 1, unmatched: [ROW], excusedChecks: 0, rowScopes: [] }
      const r = run({}, {
        self: { summary: baseSummary({ excuse: un }), nodeCoverage: { mismatches: 0 }, entries: [] },
        sibling: { summary: baseSummary({ excuse: un }), nodeCoverage: { mismatches: 0 }, entries: [] },
      })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S6)')
      expect(r.out).toContain(ROW)
    })

    // ⭐ 本条就是「⛔ 别给 React 照套单链 shrink-only」那个决定的可执行形式。
    // 落地当日实测的真实反例：`Table | rootBorderHex | *--type-tbody--*` 在 Vue 命中、React 不命中。
    it('只在本链不命中、姊妹链仍生效 → **不判 stale**（绿），并如实自印「别删」', () => {
      const r = run({}, {
        self: { summary: baseSummary({ excuse: { rows: 2, matched: 1, unmatched: [ROW], excusedChecks: 0, rowScopes: [] } }), nodeCoverage: { mismatches: 0 }, entries: [] },
        sibling: { summary: baseSummary(), nodeCoverage: { mismatches: 0 }, entries: [] },
      })
      expect(r.code).toBe(0)
      expect(r.out).toContain('不是 stale，别删')
      expect(r.out).toContain(ROW)
    })

    it('姊妹链 report 缺失 → fail closed（⛔ 不当「没命中」，那会让 stale 集合虚大）', () => {
      const r0 = run()
      expect(r0.code).toBe(0) // 阴性对照：只差删文件这一件事
      fs.rmSync(path.join(dir, 'sibling.json'))
      const out: string[] = []
      const io = { log: (...a: unknown[]) => out.push(String(a[0])), error: (...a: unknown[]) => out.push(String(a[0])) }
      const code = runRenderDriftGate(
        {
          label: 'test-gate',
          reportPath: path.join(dir, 'self.json'),
          manifestPath: path.join(dir, 'manifest.json'),
          scopeFilter: (e: unknown[]) => e,
          refreshCommand: 'pnpm test:x',
          baselineA: 0,
          baselineNodeMismatch: 0,
          excusePath: path.join(dir, 'excuse.json'),
          siblingReportPath: path.join(dir, 'sibling.json'),
          siblingRefreshCommand: 'pnpm test:y',
          coverageNote: 'note',
          fingerprintRoot: fpRoot,
        } as Parameters<typeof runRenderDriftGate>[0],
        io,
      )
      expect(code).toBe(1)
      expect(out.join('\n')).toContain('找不到姊妹链 report')
    })

    it('姊妹链 report 陈旧（自报行数 ≠ 磁盘表行数）→ fail closed', () => {
      const r = run({}, {
        sibling: { summary: baseSummary({ excuse: { rows: 5, matched: 5, unmatched: [], excusedChecks: 0, rowScopes: [] } }), nodeCoverage: { mismatches: 0 }, entries: [] },
      })
      expect(r.code).toBe(1)
      expect(r.out).toContain('姊妹链 report 自报豁免行数')
    })

    it('姊妹链 report 是旧格式（无 summary.excuse）→ fail closed', () => {
      const s = baseSummary() as Record<string, unknown>
      delete s.excuse
      const r = run({}, { sibling: { summary: s, nodeCoverage: { mismatches: 0 }, entries: [] } })
      expect(r.code).toBe(1)
      expect(r.out).toContain('姊妹链 report 缺 summary.excuse')
    })
  })

  // INFRA-F100 收口（2026-08-17）：豁免表被两条链共用，而行的理由原本全按 Vue 侧写就。
  // S8 要求每行带 React/CE 复核痕迹。⚠️ 每条都保持 rows=2 不变，好让 S6b 的行数比对照样
  // 通过 —— 否则「红了」可能是 S6b 抓的，不是 S8。
  describe('S8 CE/React 复核痕迹必填', () => {
    const withoutCe = (patch: Record<string, unknown>) => [
      { ...EXCUSE_ROWS[0], ...patch },
      EXCUSE_ROWS[1],
    ]

    it('阴性对照：完整的两行 → 绿，并自印最旧一条 ceReviewedAt', () => {
      const r = run()
      expect(r.code).toBe(0)
      expect(r.out).toContain('ceReviewedAt=2026-08-17')
      // ⛔ 不该出现恒真的 `N/N` 比值自印（走到这里 S8 已挡掉不达标的，那个比值恒等于 1）
      expect(r.out).not.toContain('2/2 行带痕迹')
    })

    it('缺 ceReviewedAt → 红并点名该行', () => {
      const row = { ...EXCUSE_ROWS[0] } as Record<string, unknown>
      delete row.ceReviewedAt
      const r = run({}, { excuse: [row, EXCUSE_ROWS[1]] })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S8)')
      expect(r.out).toContain('Tooltips | rootWidth | *')
    })

    it('ceNote 是全空白 → 红（占位不算复核）', () => {
      const r = run({}, { excuse: withoutCe({ ceNote: '   ' }) })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S8)')
    })

    it('ceReviewedAt 不是 ISO 日期 → 红', () => {
      const r = run({}, { excuse: withoutCe({ ceReviewedAt: 'reviewed' }) })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S8)')
    })

    it('ceReviewedAt 在未来 → 红（未来 = 还没真做）', () => {
      const r = run({}, { excuse: withoutCe({ ceReviewedAt: '2099-01-01' }) })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S8)')
    })

    it('自印的「最旧一条」取的是真最小值，不是第一行', () => {
      const r = run({}, {
        excuse: [
          { ...EXCUSE_ROWS[0], ceReviewedAt: '2026-08-17' },
          { ...EXCUSE_ROWS[1], ceReviewedAt: '2026-08-10' },
        ],
      })
      expect(r.code).toBe(0)
      expect(r.out).toContain('ceReviewedAt=2026-08-10')
    })
  })

  // INFRA-F99（2026-08-20）：S9 = report 的新鲜度。这是本闸唯一一条不问「report 里的数字
  // 对不对」、而问「这份 report 见过现在这份代码吗」的判据。
  // ⚠️ 每条用例都**只**动指纹相关的那一个变量，其余字段保持能过 S1–S8 —— 否则「红了」
  //    可能是别的判据抓的。
  describe('S9 report 新鲜度（输入集内容指纹）', () => {
    it('阴性对照：指纹与现算一致 → 绿，并自印指纹前缀 + 输入面文件数', () => {
      const r = run()
      expect(r.code).toBe(0)
      expect(r.out).toContain('INFRA-F99 新鲜度')
      expect(r.out).toContain(liveFingerprint().value.slice(0, 16))
      expect(r.out).toContain(`输入面 ${liveFingerprint().files} 个文件`)
      // ⛔ 不该出现恒真的「指纹匹配 ✓」——走到这里它必然匹配，那种行结论相反时也会照样打印。
      expect(r.out).not.toContain('指纹匹配')
    })

    it('⭐ 本体：产 report 之后有人改了 src → 红，且点名「没见过现在这份代码」', () => {
      // 先照常产一份「当时是对的」report，再改输入 —— 这就是 F99 描述的那个场景。
      const stale = baseReport()
      fs.writeFileSync(path.join(fpRoot, 'src/components/Button.vue'), '<template>CHANGED</template>\n')
      const r = run({}, { self: stale })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S9)')
      expect(r.out).toContain('没见过')
    })

    it('改 manifest 内容也算输入变（不只是 src）', () => {
      const stale = baseReport()
      fs.writeFileSync(path.join(fpRoot, 'figma-data/render-verification-manifest.json'), '[{"id":9}]\n')
      const r = run({}, { self: stale })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S9)')
    })

    it('report 完全没有 inputsFingerprint（旧格式）→ fail closed（⛔ 不当「输入没变」）', () => {
      const old = baseReport() as Record<string, unknown>
      delete old.inputsFingerprint
      // ⚠️ 绕过 run() 的自动补齐：这条用例测的正是「字段缺失」本身。
      const reportPath = write('self.json', old)
      const siblingReportPath = write('sibling.json', baseReport())
      const manifestPath = write('manifest.json', [{ codeComponent: 'Button' }, { codeComponent: 'Tooltip' }])
      const excusePath = write('excuse.json', EXCUSE_ROWS)
      const out: string[] = []
      const io = { log: (...a: unknown[]) => out.push(String(a[0])), error: (...a: unknown[]) => out.push(String(a[0])) }
      const code = runRenderDriftGate(
        {
          label: 'test-gate', reportPath, manifestPath, scopeFilter: (e: unknown[]) => e,
          refreshCommand: 'pnpm test:x', baselineA: 0, baselineNodeMismatch: 0, excusePath,
          siblingReportPath, siblingRefreshCommand: 'pnpm test:y', coverageNote: 'note',
          fingerprintRoot: fpRoot,
        } as Parameters<typeof runRenderDriftGate>[0],
        io,
      )
      expect(code).toBe(1)
      expect(out.join('\n')).toContain('缺 inputsFingerprint')
    })

    it('inputsFingerprint 形状不对（value 不是字符串）→ fail closed', () => {
      const r = run({}, { self: baseReport({ inputsFingerprint: { algo: 'sha256-render-inputs-v1', value: 123, files: 3 } }) })
      expect(r.code).toBe(1)
      expect(r.out).toContain('FAIL (S9)')
    })

    it('⭐ 算法版本不同 → 报「算法换过」而不是「代码变了」（两种情况必须能分开）', () => {
      const r = run({}, { self: baseReport({ inputsFingerprint: { ...liveFingerprint(), algo: 'sha256-render-inputs-v0' } }) })
      expect(r.code).toBe(1)
      expect(r.out).toContain('指纹算法版本不同')
      expect(r.out).not.toContain('没见过')
    })

    it('输入路径整个不存在 → fail closed（「算不出指纹」≠「指纹没变」）', () => {
      // ⚠️ 先把 report 造好（那时还算得出指纹），再删输入 —— 否则炸的是测试自己，不是闸。
      const report = baseReport()
      fs.rmSync(path.join(fpRoot, 'src'), { recursive: true })
      const r = run({}, { self: report, sibling: report })
      expect(r.code).toBe(1)
      expect(r.out).toContain('输入指纹算不出来')
    })

    it('最窄读法的可执行形式：改 harness / docs CSS 那一类路径 → S9 **不红**（如实登记的边界）', () => {
      const stale = baseReport()
      fs.mkdirSync(path.join(fpRoot, 'playground/docs'), { recursive: true })
      fs.writeFileSync(path.join(fpRoot, 'playground/docs/docs.css'), '.x{color:red}\n')
      const r = run({}, { self: stale })
      // ⚠️ 这条绿**不是**「已确认无影响」，是输入面的诚实边界。它同时是三条重开条件里
      //    第 ② 条（owner 若裁定输入面应含 harness）的回归钉：那天这条会翻成红，是预期的。
      expect(r.code).toBe(0)
    })

    it('姊妹链 report 的指纹**不**参与 S9（只判本链自己那份）', () => {
      // gates.yml 只跑 Vue 验证 + Vue 闸，姊妹链读的是已提交的那份 React report ——
      // 若 S9 连姊妹链一起判，那条路径会因「姊妹 report 指纹旧」恒红。
      const r = run({}, { sibling: baseReport({ inputsFingerprint: { algo: 'sha256-render-inputs-v1', value: 'f'.repeat(64), files: 1 } }) })
      expect(r.code).toBe(0)
    })
  })
})
