cleanup-deprecated.test.mjs

  1import { describe, it, beforeEach, afterEach } from 'node:test';
  2import assert from 'node:assert/strict';
  3import { mkdirSync, writeFileSync, readFileSync, existsSync, symlinkSync, rmSync } from 'node:fs';
  4import { join } from 'node:path';
  5import { mkdtempSync } from 'node:fs';
  6import { tmpdir } from 'node:os';
  7
  8import {
  9  findProjectRoot,
 10  isImpeccableSkill,
 11  buildTargetNames,
 12  findSkillsDirs,
 13  removeDeprecatedSkills,
 14  cleanSkillsLock,
 15  cleanup,
 16} from '../source/skills/impeccable/scripts/cleanup-deprecated.mjs';
 17
 18function makeTmpDir() {
 19  return mkdtempSync(join(tmpdir(), 'impeccable-cleanup-test-'));
 20}
 21
 22function writeSkill(root, harness, name, content) {
 23  const dir = join(root, harness, 'skills', name);
 24  mkdirSync(dir, { recursive: true });
 25  writeFileSync(join(dir, 'SKILL.md'), content, 'utf-8');
 26  return dir;
 27}
 28
 29describe('cleanup-deprecated', () => {
 30  let tmp;
 31
 32  beforeEach(() => {
 33    tmp = makeTmpDir();
 34    // Mark as project root
 35    writeFileSync(join(tmp, 'package.json'), '{}', 'utf-8');
 36  });
 37
 38  afterEach(() => {
 39    rmSync(tmp, { recursive: true, force: true });
 40  });
 41
 42  describe('findProjectRoot', () => {
 43    it('finds directory with package.json', () => {
 44      const sub = join(tmp, 'a', 'b', 'c');
 45      mkdirSync(sub, { recursive: true });
 46      assert.equal(findProjectRoot(sub), tmp);
 47    });
 48
 49    it('finds directory with skills-lock.json', () => {
 50      const root2 = makeTmpDir();
 51      writeFileSync(join(root2, 'skills-lock.json'), '{}', 'utf-8');
 52      assert.equal(findProjectRoot(root2), root2);
 53      rmSync(root2, { recursive: true, force: true });
 54    });
 55  });
 56
 57  describe('isImpeccableSkill', () => {
 58    it('returns true when SKILL.md mentions impeccable', () => {
 59      const dir = writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
 60      assert.equal(isImpeccableSkill(dir), true);
 61    });
 62
 63    it('returns false when SKILL.md does not mention impeccable', () => {
 64      const dir = writeSkill(tmp, '.claude', 'arrange', 'This is my custom arrange skill.');
 65      assert.equal(isImpeccableSkill(dir), false);
 66    });
 67
 68    it('returns false for non-existent directory', () => {
 69      assert.equal(isImpeccableSkill(join(tmp, 'nope')), false);
 70    });
 71  });
 72
 73  describe('buildTargetNames', () => {
 74    it('includes both unprefixed and i-prefixed names', () => {
 75      const names = buildTargetNames();
 76      assert.ok(names.includes('arrange'));
 77      assert.ok(names.includes('i-arrange'));
 78      assert.ok(names.includes('frontend-design'));
 79      assert.ok(names.includes('i-frontend-design'));
 80      assert.equal(names.length, 12); // 6 deprecated * 2
 81    });
 82  });
 83
 84  describe('findSkillsDirs', () => {
 85    it('finds existing harness skill directories', () => {
 86      mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
 87      mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
 88      const dirs = findSkillsDirs(tmp);
 89      assert.equal(dirs.length, 2);
 90    });
 91
 92    it('ignores non-existent harness directories', () => {
 93      const dirs = findSkillsDirs(tmp);
 94      assert.equal(dirs.length, 0);
 95    });
 96  });
 97
 98  describe('removeDeprecatedSkills', () => {
 99    it('deletes impeccable-owned deprecated skill directories', () => {
100      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
101      writeSkill(tmp, '.claude', 'normalize', 'Run impeccable teach.');
102      const deleted = removeDeprecatedSkills(tmp);
103      assert.equal(deleted.length, 2);
104      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
105      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'normalize')), false);
106    });
107
108    it('does NOT delete skills that do not mention impeccable', () => {
109      writeSkill(tmp, '.claude', 'arrange', 'My custom layout organizer.');
110      const deleted = removeDeprecatedSkills(tmp);
111      assert.equal(deleted.length, 0);
112      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), true);
113    });
114
115    it('deletes i-prefixed variants', () => {
116      writeSkill(tmp, '.cursor', 'i-normalize', 'Invoke /impeccable first.');
117      const deleted = removeDeprecatedSkills(tmp);
118      assert.equal(deleted.length, 1);
119      assert.equal(existsSync(join(tmp, '.cursor', 'skills', 'i-normalize')), false);
120    });
121
122    it('cleans across multiple harness directories', () => {
123      writeSkill(tmp, '.claude', 'onboard', 'Run impeccable teach first.');
124      writeSkill(tmp, '.agents', 'onboard', 'Run impeccable teach first.');
125      writeSkill(tmp, '.cursor', 'onboard', 'Run impeccable teach first.');
126      const deleted = removeDeprecatedSkills(tmp);
127      assert.equal(deleted.length, 3);
128    });
129
130    it('leaves non-deprecated skills alone', () => {
131      writeSkill(tmp, '.claude', 'polish', 'Invoke /impeccable first.');
132      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
133      const deleted = removeDeprecatedSkills(tmp);
134      assert.equal(deleted.length, 1); // only arrange
135      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'polish')), true);
136    });
137
138    it('handles symlinks to deprecated skills', () => {
139      // Create the canonical skill in .agents
140      const canonical = writeSkill(tmp, '.agents', 'extract', 'Use impeccable extract.');
141      // Create a symlink in .claude
142      mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
143      symlinkSync(canonical, join(tmp, '.claude', 'skills', 'extract'));
144      const deleted = removeDeprecatedSkills(tmp);
145      assert.equal(deleted.length, 2); // both canonical and symlink
146    });
147  });
148
149  describe('cleanSkillsLock', () => {
150    it('removes impeccable-owned deprecated entries', () => {
151      const lock = {
152        version: 1,
153        skills: {
154          arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
155          polish: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
156          'resolve-reviews': { source: 'pbakaus/agent-reviews', sourceType: 'github', computedHash: 'ghi' },
157        },
158      };
159      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
160      const removed = cleanSkillsLock(tmp);
161      assert.deepEqual(removed, ['arrange']);
162      const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
163      assert.equal(updated.skills.arrange, undefined);
164      assert.ok(updated.skills.polish); // not deprecated
165      assert.ok(updated.skills['resolve-reviews']); // different source
166    });
167
168    it('does NOT remove entries from other sources', () => {
169      const lock = {
170        version: 1,
171        skills: {
172          extract: { source: 'some-other/package', sourceType: 'github', computedHash: 'xyz' },
173        },
174      };
175      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
176      const removed = cleanSkillsLock(tmp);
177      assert.equal(removed.length, 0);
178    });
179
180    it('handles missing skills-lock.json gracefully', () => {
181      const removed = cleanSkillsLock(tmp);
182      assert.equal(removed.length, 0);
183    });
184
185    it('removes i-prefixed entries', () => {
186      const lock = {
187        version: 1,
188        skills: {
189          'i-arrange': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
190          'i-normalize': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
191        },
192      };
193      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
194      const removed = cleanSkillsLock(tmp);
195      assert.equal(removed.length, 2);
196    });
197  });
198
199  describe('cleanup (integration)', () => {
200    it('cleans both files and lock entries in one pass', () => {
201      // Set up deprecated skills in two harness dirs
202      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable.');
203      writeSkill(tmp, '.agents', 'arrange', 'Invoke /impeccable.');
204      writeSkill(tmp, '.claude', 'extract', 'Run impeccable extract.');
205
206      // Set up lock file
207      const lock = {
208        version: 1,
209        skills: {
210          arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'a' },
211          extract: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'b' },
212          polish: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'c' },
213        },
214      };
215      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
216
217      const result = cleanup(tmp);
218      assert.equal(result.deletedPaths.length, 3);
219      assert.equal(result.removedLockEntries.length, 2); // arrange + extract
220      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
221      assert.equal(existsSync(join(tmp, '.agents', 'skills', 'arrange')), false);
222
223      const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
224      assert.ok(updated.skills.polish);
225      assert.equal(updated.skills.arrange, undefined);
226      assert.equal(updated.skills.extract, undefined);
227    });
228
229    it('is a no-op when nothing needs cleaning', () => {
230      writeSkill(tmp, '.claude', 'polish', 'Invoke /impeccable.');
231      const result = cleanup(tmp);
232      assert.equal(result.deletedPaths.length, 0);
233      assert.equal(result.removedLockEntries.length, 0);
234    });
235  });
236});