/** * 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(''); }); });