import { describe, it, expect } from 'vitest'
import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { execFileSync } from 'node:child_process'
import { join, resolve } from 'node:path'
import {
  parseNameDate,
  daysBetween,
  isArchivePath,
  evaluate,
  computeInboundRefs,
  run,
  PLAN_MENTION_RE,
  isProvenanceMention,
  undatedMentionRe,
  MANAGED_DIRS,
  GRACE_DAYS,
  EXEMPTIONS,
  REF_SCAN_DIRS,
  REF_EXTS,
  refScanRootFiles,
  REF_SKIP_DIRS,
  REF_SKIP_PATHS,
  collectRefFiles,
} from '../scripts/audit-plan-lifecycle.mjs'

const REPO_ROOT = resolve(__dirname, '..')

/** 造一个最小 managed 形状，供 evaluate 单独驱动（不碰真仓库） */
function managedOf(
  files: { name: string; bytes?: number }[],
  refs: Record<string, string[]> = {},
  over: Record<string, unknown> = {},
) {
  const dir = (over.dir as string) ?? 'docs/superpowers/plans'
  return [
    {
      label: 'test',
      dir,
      archiveDir: 'docs/_archive/superpowers/plans',
      files: files.map((f) => ({ name: f.name, bytes: f.bytes ?? 100 })),
      archivedCount: 0,
      dirExists: true,
      archiveExists: true,
      // key = `${dir}/${name}`，与 computeInboundRefs 一致（basename 键会跨目录同名顶替）
      refs: new Map(files.map((f) => [`${dir}/${f.name}`, refs[`${dir}/${f.name}`] ?? []])),
      ...over,
    },
  ] as Parameters<typeof evaluate>[0]['managed']
}

const codes = (r: ReturnType<typeof evaluate>) => r.findings.map((f) => f.code).sort()

describe('parseNameDate — S1 的形态判据（日期已解绑，只验真伪）', () => {
  it('日期前缀 → 解析出日期', () => {
    expect(parseNameDate('2026-08-03-infra-f96-lifecycle.md')).toEqual({
      iso: '2026-08-03',
      claimsDate: true,
    })
  })

  it('日期在中间也解析得出（_plans/ 的 pickup 就是这形态）', () => {
    expect(parseNameDate('next-session-pickup-2026-08-03-doc-volume.md')).toEqual({
      iso: '2026-08-03',
      claimsDate: true,
    })
  })

  it('无日期 → 合法形态，不是红（claimsDate=false）', () => {
    expect(parseNameDate('upstream-gate-codex.prompt.md')).toEqual({
      iso: null,
      claimsDate: false,
    })
    expect(parseNameDate('README.md')).toEqual({ iso: null, claimsDate: false })
    expect(parseNameDate('some-plan.md')).toEqual({ iso: null, claimsDate: false })
  })

  it('形似日期但不是真日期 → claimsDate=true 且 iso=null（S1 据此判红）', () => {
    expect(parseNameDate('2026-13-45-foo.md')).toEqual({ iso: null, claimsDate: true })
    expect(parseNameDate('2026-02-30-foo.md')).toEqual({ iso: null, claimsDate: true })
  })

  it('只有日期没有 slug → 仍判红（保留旧 S1 的这半条，别静默丢掉）', () => {
    expect(parseNameDate('2026-08-03.md')).toEqual({ iso: null, claimsDate: true })
  })

  it('非 .md → 形态错 fail closed（保留旧 S1 的这半条）', () => {
    expect(parseNameDate('2026-08-03-foo.txt')).toEqual({ iso: null, claimsDate: true })
    expect(parseNameDate('README.txt')).toEqual({ iso: null, claimsDate: true })
  })

  it('多个日期取第一个', () => {
    expect(parseNameDate('2026-01-02-vs-2026-03-04-compare.md').iso).toBe('2026-01-02')
  })
})

describe('daysBetween', () => {
  it('同日 = 0，隔日 = 1', () => {
    expect(daysBetween('2026-08-03', '2026-08-03')).toBe(0)
    expect(daysBetween('2026-08-02', '2026-08-03')).toBe(1)
  })

  it('跨月正确（不是简单减日）', () => {
    expect(daysBetween('2026-07-31', '2026-08-03')).toBe(3)
  })
})

describe('isArchivePath — 归档目录不算引用来源', () => {
  it('_archive 下的任何深度都算归档', () => {
    expect(isArchivePath('docs/_archive/superpowers/plans/x.md')).toBe(true)
    expect(isArchivePath('docs/_archive/_plans/x.md')).toBe(true)
  })

  it('live 路径不算', () => {
    expect(isArchivePath('docs/superpowers/plans/x.md')).toBe(false)
    expect(isArchivePath('docs/STATUS.md')).toBe(false)
  })

  it('不做子串匹配（`_archived-notes` 不是 `_archive`）', () => {
    expect(isArchivePath('docs/_archived-notes/x.md')).toBe(false)
  })
})

describe('evaluate S2 — 可达性', () => {
  const old = [{ name: '2026-01-01-old-plan.md' }]

  it('must-fire：超 grace 且零引用 → S2 红', () => {
    const r = evaluate({ managed: managedOf(old), today: '2026-08-03' })
    expect(codes(r)).toEqual(['S2'])
    expect(r.findings[0].msg).toContain('2026-01-01-old-plan.md')
  })

  it('must-not-fire：超 grace 但有 1 条引用 → 绿', () => {
    const r = evaluate({
      managed: managedOf(old, { 'docs/superpowers/plans/2026-01-01-old-plan.md': ['docs/STATUS.md'] }),
      today: '2026-08-03',
    })
    expect(r.findings).toEqual([])
  })

  it('must-not-fire：零引用但在 grace 期内 → 绿（今天刚写的 plan 不该被逼归档）', () => {
    const r = evaluate({ managed: managedOf([{ name: '2026-08-03-fresh.md' }]), today: '2026-08-03' })
    expect(r.findings).toEqual([])
  })

  it('grace 边界精确：第 1 天绿、第 2 天红', () => {
    const day1 = evaluate({ managed: managedOf([{ name: '2026-08-02-x.md' }]), today: '2026-08-03' })
    const day2 = evaluate({ managed: managedOf([{ name: '2026-08-01-x.md' }]), today: '2026-08-03' })
    expect(day1.findings).toEqual([])
    expect(codes(day2)).toEqual(['S2'])
  })

  it('多份孤儿 → 逐条点名，不是只报第一条', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-01-01-a.md' }, { name: '2026-01-02-b.md' }]),
      today: '2026-08-03',
    })
    expect(r.findings).toHaveLength(2)
  })
})

describe('evaluate S1 — 只验形态真伪，无日期是合法形态', () => {
  it('must-fire：名字里声称有日期但不是真日期 → S1 红', () => {
    const r = evaluate({ managed: managedOf([{ name: '2026-13-45-foo.md' }]), today: '2026-08-05' })
    expect(codes(r)).toEqual(['S1'])
  })

  it('S1 命中的文件不再走 S2（一个文件只报一条根因）', () => {
    const r = evaluate({ managed: managedOf([{ name: '2026-13-45-foo.md' }]), today: '2026-08-05' })
    expect(r.findings).toHaveLength(1)
  })

  it('must-not-fire：无日期但有引用 → 绿（常青模板不该被形态误伤）', () => {
    const r = evaluate({
      managed: managedOf([{ name: 'upstream-gate-codex.prompt.md' }], {
        'docs/superpowers/plans/upstream-gate-codex.prompt.md': ['docs/internal/backlog.md'],
      }),
      today: '2026-08-05',
    })
    expect(r.findings).toEqual([])
  })

  it('must-fire：无日期且零引用 → S2 红（无 grace 可躲）', () => {
    const r = evaluate({
      managed: managedOf([{ name: 'upstream-gate-codex.prompt.md' }]),
      today: '2026-08-05',
    })
    expect(codes(r)).toEqual(['S2'])
  })
})

describe('evaluate S3 — 扫描面 fail closed（防空转成假绿）', () => {
  it('目录不存在 → 红', () => {
    const r = evaluate({ managed: managedOf([], {}, { dirExists: false }), today: '2026-08-03' })
    expect(codes(r)).toEqual(['S3'])
  })

  it('目录在但一个 .md 都没有 → 红（不是 exit 0）', () => {
    const r = evaluate({ managed: managedOf([]), today: '2026-08-03' })
    expect(codes(r)).toEqual(['S3'])
  })

  it('有文件到期 + 归档落点不存在 → S3 红（否则闸会要求 git mv 到一个不存在的地方）', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-01-01-old.md' }], {}, { archiveExists: false }),
      today: '2026-08-05',
    })
    expect(codes(r)).toEqual(['S2', 'S3'])
  })

  it('无文件到期 + 归档落点不存在 → 不红（不为用不上的目录先塞空 .gitkeep）', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-08-05-fresh.md' }], {}, { archiveExists: false }),
      today: '2026-08-05',
    })
    expect(r.findings).toEqual([])
  })
})

describe('refScanRootFiles — 根文件扫描面动态列举（硬编码清单曾造成假阳）', () => {
  it('真仓库：根下 6 份 tracked .md 一个不漏（原硬编码只有 4 个，漏了 CHANGELOG.md）', () => {
    const got = refScanRootFiles(REPO_ROOT)
    for (const n of ['AGENTS.md', 'BRANDING.md', 'CHANGELOG.md', 'CLAUDE.md', 'CONTRIBUTING.md', 'README.md']) {
      expect(got, `根文件扫描面漏了 ${n}`).toContain(n)
    }
  })

  it('回归钉：CHANGELOG.md 里那条 pickup 引用能被闸看见（假阳的原始现场）', () => {
    const refs = computeInboundRefs([
      { dir: 'docs/internal/_plans', name: 'new-session-prompt-consumer-product-verification.md' },
    ])
    expect(
      refs.get('docs/internal/_plans/new-session-prompt-consumer-product-verification.md'),
    ).toContain('CHANGELOG.md')
  })

  it('只收 REF_EXTS 内的扩展名，且不下钻目录', () => {
    const got = refScanRootFiles(REPO_ROOT)
    expect(got).not.toContain('docs')
    expect(got.every((n) => [...REF_EXTS].some((e) => n.endsWith(e)))).toBe(true)
  })
})

describe('真仓库回归钉 — 扩展后的受管目录表', () => {
  it('四个受管目录都真实存在', () => {
    expect(MANAGED_DIRS.map((m) => m.dir).sort()).toEqual([
      'docs/internal/_handoffs',
      'docs/internal/_plans',
      'docs/internal/_prompts',
      'docs/superpowers/plans',
    ])
    for (const m of MANAGED_DIRS) {
      expect(existsSync(resolve(REPO_ROOT, m.dir)), m.dir).toBe(true)
    }
  })

  it('记录型目录刻意不在扫描面内（靠 grep 发现不靠链接）', () => {
    const dirs = MANAGED_DIRS.map((m) => m.dir)
    for (const d of [
      'docs/internal/_spikes',
      'docs/internal/_reports',
      'docs/internal/retrospection',
      'docs/superpowers/specs',
    ]) {
      expect(dirs).not.toContain(d)
    }
  })
})

describe('evaluate S4 — 豁免表 shrink-only', () => {
  const ex = [
    { dir: 'docs/superpowers/plans', file: '2026-01-01-old-plan.md', addedAt: '2026-08-03', reason: 't' },
  ]

  it('豁免命中时压住 S2', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-01-01-old-plan.md' }]),
      today: '2026-08-03',
      exemptions: ex,
    })
    expect(r.findings).toEqual([])
  })

  it('豁免已不再命中（该文件补上引用了）→ S4 红，要求删行', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-01-01-old-plan.md' }], {
        'docs/superpowers/plans/2026-01-01-old-plan.md': ['docs/STATUS.md'],
      }),
      today: '2026-08-03',
      exemptions: ex,
    })
    expect(codes(r)).toEqual(['S4'])
  })

  it('豁免指向已不存在的文件 → S4 红（归档完要删掉那行）', () => {
    const r = evaluate({
      managed: managedOf([{ name: '2026-08-03-other.md' }]),
      today: '2026-08-03',
      exemptions: ex,
    })
    expect(codes(r)).toEqual(['S4'])
  })
})

describe('PLAN_MENTION_RE — 引用抽取（连路径前缀一起吃）', () => {
  it('抽得出散文里的引用，且带上路径前缀', () => {
    const t = '见 [`plans/2026-07-30-v1x-next-batch.md`](x) 与 docs/superpowers/plans/2026-08-03-a.md。'
    expect(t.match(PLAN_MENTION_RE)).toEqual([
      'plans/2026-07-30-v1x-next-batch.md',
      'docs/superpowers/plans/2026-08-03-a.md',
    ])
  })

  it('不误抽非 .md', () => {
    expect('2026-08-03-report.json'.match(PLAN_MENTION_RE)).toBeNull()
  })
})

describe('isProvenanceMention — 溯源引用不算活指针', () => {
  it('指向归档路径 = 溯源，不算', () => {
    expect(isProvenanceMention('docs/_archive/superpowers/plans/2026-04-22-x.md')).toBe(true)
  })

  it('指向 live 路径 = 活指针，算', () => {
    expect(isProvenanceMention('docs/superpowers/plans/2026-04-22-x.md')).toBe(false)
    expect(isProvenanceMention('plans/2026-04-22-x.md')).toBe(false)
    expect(isProvenanceMention('2026-04-22-x.md')).toBe(false)
  })

  it('回归钉：抢救注释引归档路径时，被抢救的那份 plan 仍判为孤儿', () => {
    // 2026-08-03 实测撞上的自锁：抢救注释写了 plan 文件名 → 那份 plan 反而归档不掉，
    // 越认真做搬迁越锁得死。判据必须能区分「历史出处」和「它还在飞」。
    const text = '（2026-08-03 归档 docs/_archive/superpowers/plans/2026-01-01-old-plan.md 时抢救出来的约束）'
    const hits = (text.match(PLAN_MENTION_RE) ?? []).filter((h) => !isProvenanceMention(h))
    expect(hits).toEqual([])
  })
})

describe('真仓库回归钉', () => {
  it('MANAGED_DIRS 的受管目录都真实存在（配置漂了要当场红）', () => {
    for (const m of MANAGED_DIRS) {
      expect(existsSync(resolve(REPO_ROOT, m.dir)), `${m.dir} 不存在`).toBe(true)
    }
  })

  it('归档落点路径形态正确，但**不要求已存在** —— 落点按需建，由 S3 条件化判', () => {
    // 旧版这里断言「每个 archiveDir 都必须存在」，与 S3 条件化直接冲突：
    // 一个还没有文件到期的目录（如 _handoffs）本就不该先建空落点。
    // 「真需要时落点必须在」由 S3 管，真仓库那一面由下面的 run() 零 finding 钉住。
    for (const m of MANAGED_DIRS) {
      expect(m.archiveDir, `${m.dir} 的归档落点不在 docs/_archive/ 下`).toMatch(
        /^docs\/_archive\//,
      )
    }
  })

  it('REF_SCAN_DIRS 里至少 STATUS 所在的 docs/ 在（引用扫描面 fail closed）', () => {
    expect(REF_SCAN_DIRS).toContain('docs')
  })

  it('specs/ 刻意不在受管目录里 —— 设计记录无限期保留', () => {
    expect(MANAGED_DIRS.some((m) => m.dir.includes('/specs'))).toBe(false)
  })

  it('grace = 1 天（改这个值等于改判据，要连 spec 一起改）', () => {
    expect(GRACE_DAYS).toBe(1)
  })

  it('豁免表为空 —— 空是终态，不是待办', () => {
    expect(EXEMPTIONS).toEqual([])
  })

  it('真仓库跑 run() 零 finding（本闸落地后的常态）', () => {
    // 直接用 run()，别在测试里重算一遍判据 —— 重算等于把判据写两份，会各说一套
    const { findings, report } = run()
    expect(report.length, '分母 fail closed：一份都没扫到就是空过').toBeGreaterThan(0)
    expect(findings.map((f) => `[${f.code}] ${f.msg}`)).toEqual([])
  })
})

describe('undatedMentionRe — 第二遍：无日期文件的引用匹配', () => {
  it('带父目录最后一段的引用算数', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'upstream-gate-codex.prompt.md')
    re.lastIndex = 0
    expect(re.test('见 docs/internal/_prompts/upstream-gate-codex.prompt.md')).toBe(true)
  })

  it('相对写法也算数（所以只取最后一段，不是完整仓库路径）', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'upstream-gate-codex.prompt.md')
    re.lastIndex = 0
    expect(re.test('见 ../_prompts/upstream-gate-codex.prompt.md')).toBe(true)
  })

  it('裸名不算数 —— README.md 裸名全仓 109 命中，会让闸恒绿', () => {
    const re = undatedMentionRe('docs/internal/_plans', 'README.md')
    re.lastIndex = 0
    expect(re.test('见 README.md 里的说明')).toBe(false)
    re.lastIndex = 0
    expect(re.test('见 docs/internal/_plans/README.md')).toBe(true)
  })

  it('文件名里的 `.` 被转义（不当通配）', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'a.prompt.md')
    re.lastIndex = 0
    expect(re.test('见 _prompts/aXpromptXmd')).toBe(false)
  })

  it('bug 源①：归档路径字面包含 live 路径 → 必须被 isProvenanceMention 剔掉', () => {
    const re = undatedMentionRe('docs/internal/_prompts', 'foo.prompt.md')
    re.lastIndex = 0
    const m = re.exec('见 docs/_archive/_prompts/foo.prompt.md')
    expect(m).not.toBeNull()
    expect(isProvenanceMention(m![0])).toBe(true)
  })
})

describe('computeInboundRefs — key 是 dir/name，不是 basename', () => {
  it('跨目录同名不互相顶替（两个 README.md 各算各的）', () => {
    const refs = computeInboundRefs([
      { dir: 'docs/internal/_plans', name: 'README.md' },
      { dir: 'docs/internal/_demos', name: 'README.md' },
    ])
    expect(refs.has('docs/internal/_plans/README.md')).toBe(true)
    expect(refs.has('docs/internal/_demos/README.md')).toBe(true)
    // 真仓库钉：_plans/README.md 被带前缀指过（实测 7 处）
    expect(refs.get('docs/internal/_plans/README.md')!.length).toBeGreaterThan(0)
  })

  it('bug 源②：文件自己提到自己的路径不算引用', () => {
    const refs = computeInboundRefs([{ dir: 'docs/internal/_plans', name: 'README.md' }])
    expect(refs.get('docs/internal/_plans/README.md')).not.toContain(
      'docs/internal/_plans/README.md',
    )
  })
})

describe('第二遍的两个守卫真的接在 computeInboundRefs 上（fixture 仓库，拿掉守卫会红）', () => {
  function fixture(): string {
    const root = mkdtempSync(join(tmpdir(), 'f96-'))
    mkdirSync(join(root, 'docs/internal/_prompts'), { recursive: true })
    writeFileSync(join(root, 'docs/internal/_prompts/foo.prompt.md'), '# foo\n')
    return root
  }

  it('守卫①：live 文件里的**归档路径**提及不算活指针（抢救注释不该把它钉住）', () => {
    const root = fixture()
    // 一条负责任的抢救注释长这样 —— 它是历史出处，不是「这份还在飞」
    writeFileSync(
      join(root, 'docs/NOTES.md'),
      '本规则抢救自 docs/_archive/_prompts/foo.prompt.md\n',
    )
    const refs = computeInboundRefs([{ dir: 'docs/internal/_prompts', name: 'foo.prompt.md' }], root)
    expect(refs.get('docs/internal/_prompts/foo.prompt.md')).toEqual([])
    rmSync(root, { recursive: true, force: true })
  })

  it('对照组：同一 fixture 改成 live 路径提及 → 算活指针（证明上一条不是空过）', () => {
    const root = fixture()
    writeFileSync(join(root, 'docs/NOTES.md'), '见 docs/internal/_prompts/foo.prompt.md\n')
    const refs = computeInboundRefs([{ dir: 'docs/internal/_prompts', name: 'foo.prompt.md' }], root)
    expect(refs.get('docs/internal/_prompts/foo.prompt.md')).toEqual(['docs/NOTES.md'])
    rmSync(root, { recursive: true, force: true })
  })

  it('守卫②：文件自己提到自己的路径不算引用', () => {
    const root = fixture()
    writeFileSync(
      join(root, 'docs/internal/_prompts/foo.prompt.md'),
      '# foo\n本文件 docs/internal/_prompts/foo.prompt.md 的说明\n',
    )
    const refs = computeInboundRefs([{ dir: 'docs/internal/_prompts', name: 'foo.prompt.md' }], root)
    expect(refs.get('docs/internal/_prompts/foo.prompt.md')).toEqual([])
    rmSync(root, { recursive: true, force: true })
  })
})

describe('argv fail closed —— 不静默吞掉未知参数', () => {
  const script = resolve(REPO_ROOT, 'scripts/audit-plan-lifecycle.mjs')
  const exitCodeOf = (args: string[]): number => {
    try {
      execFileSync('node', [script, ...args], { stdio: 'pipe' })
      return 0
    } catch (e) {
      return (e as { status: number }).status
    }
  }

  it('未知参数 → exit 2（2026-08-05 前的基线是 exit 0 静默放行）', () => {
    expect(exitCodeOf(['--bogus-flag'])).toBe(2)
  })

  it('exit 2 与「判据违例」的 exit 1 区分开', () => {
    expect(exitCodeOf(['--fix'])).toBe(2)
  })

  it('pnpm 透传的空串不算参数 → 正常判定（本轮落地后真仓库是绿的）', () => {
    expect(exitCodeOf([''])).toBe(0)
  })
})

/**
 * 引用扫描面的**精确路径**排除（2026-08-25）。
 *
 * `figma-data/mockup/`（1.1 GB）被 `.gitignore` 排除、只存在于开发者主工作树 ——
 * CI runner 与 lab 的 pin worktree 上不存在。⇒ 把它放在扫描面里 = **本闸的分母不可复现**。
 * 排它的理由是这条，**不是「它大」**（处方逐字警告过不能因为大就删）。
 * 零真引用已实测（上界口径：37 个受管文件 basename 在 figma-data/ 下 0 命中，
 * 阳性对照同 grep 跑 docs/ 命中 67 份）⇒ 本条不改变任何一份文件的入站判定。
 *
 * ⚠️ 这份文件只覆盖判据逻辑与接线的这一段；「排掉后闸的红绿是否变」由落地当天的
 * 三态故障注入覆盖（零入站→红 / 引用在 tracked figma-data→绿 / 同一引用移进 mockup→红）。
 */
describe('REF_SKIP_PATHS — 精确路径排除（⛔ 不是按目录名）', () => {
  it('排掉 figma-data/mockup', () => {
    expect(REF_SKIP_PATHS.has('figma-data/mockup')).toBe(true)
  })

  it('⛔ must-not-hit：**不得**排掉整个 figma-data —— tracked 的那 67 MB 在 CI 上存在、是可复现输入面', () => {
    expect(REF_SKIP_PATHS.has('figma-data')).toBe(false)
    expect(REF_SKIP_DIRS.has('figma-data')).toBe(false)
    expect(REF_SKIP_DIRS.has('mockup')).toBe(false) // 按目录名排会命中任何一层的同名目录
  })

  it('⛔ must-not-hit：真源目录一个都不在排除面（写宽了会静默架空 S2 的分母）', () => {
    for (const d of ['docs', 'scripts', 'src', 'tests', 'skills', 'figma-sync']) {
      expect(REF_SKIP_PATHS.has(d)).toBe(false)
      expect(REF_SKIP_DIRS.has(d)).toBe(false)
    }
  })

  it('接线：collectRefFiles 在 fixture root 上**跳过** figma-data/mockup、**收下** figma-data/normalized', () => {
    const root = mkdtempSync(join(tmpdir(), 'pl-skip-'))
    try {
      mkdirSync(join(root, 'figma-data/mockup'), { recursive: true })
      mkdirSync(join(root, 'figma-data/normalized'), { recursive: true })
      mkdirSync(join(root, 'docs'), { recursive: true })
      writeFileSync(join(root, 'figma-data/mockup/should-be-skipped.json'), '{}')
      writeFileSync(join(root, 'figma-data/normalized/should-be-kept.json'), '{}')
      writeFileSync(join(root, 'docs/keep.md'), 'x')
      const files = collectRefFiles(root)
      expect(files).toContain('figma-data/normalized/should-be-kept.json')
      expect(files).toContain('docs/keep.md')
      expect(files).not.toContain('figma-data/mockup/should-be-skipped.json')
      // 阴性对照：排除是**路径**级的，mockup 这个名字在别处不受影响
      mkdirSync(join(root, 'scripts/mockup'), { recursive: true })
      writeFileSync(join(root, 'scripts/mockup/kept.mjs'), '//x')
      expect(collectRefFiles(root)).toContain('scripts/mockup/kept.mjs')
    } finally {
      rmSync(root, { recursive: true, force: true })
    }
  })
})
