repo-hygiene.test.js 5.07 KB
/**
 * Unit Tests — 仓库卫生:tracked 与 ignored 不重叠(28 二轮修复,原 blocker 6)
 *
 * 锁的不是某段代码,而是**索引与 .gitignore 的一致性**这个不变式:
 *   `system/agents/` 下深度 >2 的路径里,被 gitignore 命中的 tracked 文件必须是 0 条。
 *
 * 为什么这条不变式非锁不可(三条后果都不长成「报错」的样子):
 *   1. gitignore 对**已 tracked** 的文件完全无效。批次 1 用 `git mv` 把 system/templates/
 *      整体搬进 system/agents/ 时,把 143 个本该被 §5.5 新规则 ignore 的运行态文件
 *      一起带进了 index —— 新规则写得再对也拦不住它们。
 *   2. graph-engine 的 beginBoundaryGuard / applyBoundaryGuard 每次 agent 调用前后各拍一次
 *      整仓 git status,diff 非空就把节点判 `boundary_escape` fail。agent 按相对路径写进
 *      自己的 workspace/ 就会改动这些 tracked 文件 → **本来成功的节点被判失败,
 *      而且归因指向当时正在跑的那个节点**(28 风险表第 1 行)。
 *   3. 于是 §5.5 验收 3「跑一次真 agent 调用后 git status 增量为空」结构性不可能达成。
 *
 * 判据用 `--no-index`:不加这个参数时 git check-ignore 会跳过已在索引里的路径,
 * 而「已在索引里」恰恰是本测试唯一关心的那批 —— 不加就永远绿,是最典型的「隔离是表演」。
 */
import { describe, it, expect } from 'vitest';
import { execSync, spawnSync } from 'child_process';
import fs from 'fs';
import path from 'path';

const REPO_ROOT = path.resolve(__dirname, '../../..');

const trackedUnderAgents = () =>
  execSync('git ls-files system/agents', { cwd: REPO_ROOT, encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024 })
    .split('\n')
    .filter(Boolean);

/** 命中 gitignore 的那些(空列表进空列表出,check-ignore 退出码 1 不当异常) */
function ignoredAmong(paths) {
  if (!paths.length) return [];
  const r = spawnSync('git', ['check-ignore', '--no-index', '--stdin'], {
    cwd: REPO_ROOT, input: paths.join('\n'), encoding: 'utf-8', maxBuffer: 64 * 1024 * 1024,
  });
  return (r.stdout || '').split('\n').filter(Boolean);
}

describe('仓库卫生(28 §5.5)', () => {
  it('system/agents 下深度 >2 的 tracked 路径,没有一条命中 gitignore', () => {
    const tracked = trackedUnderAgents();
    // 先证明样本非空:本体目录下的 .claude/skills、train/、docs/ 都是深度 >2 的合法 tracked 路径。
    // 少了这一步,哪天 system/agents 整个被误 ignore,下面那条断言反而会变绿。
    const deep = tracked.filter((p) => p.split('/').length > 3);
    expect(deep.length).toBeGreaterThan(0);

    expect(ignoredAmong(deep)).toEqual([]);
  });

  it('反向:磁盘上每一份 system/agents/*/agent.md 都必须可被 git 跟踪', () => {
    // 上一条锁的是「ignore 规则没漏」,这条锁的是**反方向**:ignore 规则没误伤本体。
    // 两条都必要,因为 §5.5 的白名单写法是「先 `system/agents/*/*/` 全排除、再 ! 回几处」——
    // 任何一条新加的排除规则(哪怕只是想挡掉某个运行态目录)都可能顺手把本体根盖住。
    //
    // 后果同样不长成报错的样子:本体被 ignore → 它不进版本库 → 别的机器 / 下一次 clone
    // 拿到的是「目录在、agent.md 不在」的空壳,promptBuilder 读不到定义时**不抛异常**,
    // agent 静默退化成通用 Base Agent 文本(28 风险表第 3 行的同款静默降级)。
    //
    // 判据取**磁盘**而不是 git ls-files:新建出来还没 add 的本体(G4 create-agent 的产物、
    // 测试跑出来的注册表条目)正是最容易被规则误伤的一批,它们不在 index 里,
    // 用 ls-files 取样就恰好把该测的样本漏掉了。
    // 仍要 `--no-index`:已 tracked 的本体会被 check-ignore 直接跳过,不加就只测到一半。
    const agentsRoot = path.join(REPO_ROOT, 'system/agents');
    const bodies = fs.readdirSync(agentsRoot, { withFileTypes: true })
      .filter((e) => e.isDirectory())
      .map((e) => `system/agents/${e.name}/agent.md`)
      .filter((rel) => fs.existsSync(path.join(REPO_ROOT, rel)));

    // 样本非空自证:本仓有 50+ 个本体,取样为 0 说明扫描路径写错了,否则下面那条恒绿。
    expect(bodies.length).toBeGreaterThan(10);
    expect(ignoredAmong(bodies)).toEqual([]);
  });

  it('两个迁移备份根都被 ignore(178MB 的快照不进版本库)', () => {
    // 通配 `data/_migration-backup-*/` 写的是**目录**规则,只对目录名生效;
    // 逐个列名的老写法漏过了 _migration-backup-2026-08-17,那份 178MB 一直挂在 git status 里。
    const roots = ['data/_migration-backup-agentdirs/x', 'data/_migration-backup-2026-08-17/x'];
    expect(ignoredAmong(roots).sort()).toEqual(roots.sort());
    expect(execSync('git ls-files data/_migration-backup-agentdirs data/_migration-backup-2026-08-17',
      { cwd: REPO_ROOT, encoding: 'utf-8' }).trim()).toBe('');
  });
});