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  loadLock,
 17} from '../skill/scripts/cleanup-deprecated.mjs';
 18
 19function makeTmpDir() {
 20  return mkdtempSync(join(tmpdir(), 'impeccable-cleanup-test-'));
 21}
 22
 23function writeSkill(root, harness, name, content) {
 24  const dir = join(root, harness, 'skills', name);
 25  mkdirSync(dir, { recursive: true });
 26  writeFileSync(join(dir, 'SKILL.md'), content, 'utf-8');
 27  return dir;
 28}
 29
 30describe('cleanup-deprecated', () => {
 31  let tmp;
 32
 33  beforeEach(() => {
 34    tmp = makeTmpDir();
 35    // Mark as project root
 36    writeFileSync(join(tmp, 'package.json'), '{}', 'utf-8');
 37  });
 38
 39  afterEach(() => {
 40    rmSync(tmp, { recursive: true, force: true });
 41  });
 42
 43  describe('findProjectRoot', () => {
 44    it('finds directory with package.json', () => {
 45      const sub = join(tmp, 'a', 'b', 'c');
 46      mkdirSync(sub, { recursive: true });
 47      assert.equal(findProjectRoot(sub), tmp);
 48    });
 49
 50    it('finds directory with skills-lock.json', () => {
 51      const root2 = makeTmpDir();
 52      writeFileSync(join(root2, 'skills-lock.json'), '{}', 'utf-8');
 53      assert.equal(findProjectRoot(root2), root2);
 54      rmSync(root2, { recursive: true, force: true });
 55    });
 56  });
 57
 58  describe('isImpeccableSkill', () => {
 59    it('returns true when SKILL.md mentions impeccable', () => {
 60      const dir = writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
 61      assert.equal(isImpeccableSkill(dir), true);
 62    });
 63
 64    it('returns false when SKILL.md does not mention impeccable', () => {
 65      const dir = writeSkill(tmp, '.claude', 'arrange', 'This is my custom arrange skill.');
 66      assert.equal(isImpeccableSkill(dir), false);
 67    });
 68
 69    it('returns false for non-existent directory', () => {
 70      assert.equal(isImpeccableSkill(join(tmp, 'nope')), false);
 71    });
 72
 73    it('returns true when lock source says pbakaus/impeccable, even if SKILL.md never mentions it', () => {
 74      const dir = writeSkill(tmp, '.claude', 'harden', 'A custom skill with no pack mention.');
 75      const lock = {
 76        skills: { harden: { source: 'pbakaus/impeccable' } },
 77      };
 78      assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), true);
 79    });
 80
 81    it('returns false when lock source is a different pack', () => {
 82      const dir = writeSkill(tmp, '.claude', 'harden', 'A custom skill with no pack mention.');
 83      const lock = {
 84        skills: { harden: { source: 'someone-else/pack' } },
 85      };
 86      assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), false);
 87    });
 88
 89    it('falls back to SKILL.md content when no lock entry exists', () => {
 90      const dir = writeSkill(tmp, '.claude', 'harden', 'Invoke /impeccable to harden.');
 91      const lock = { skills: {} };
 92      assert.equal(isImpeccableSkill(dir, { skillName: 'harden', lock }), true);
 93    });
 94  });
 95
 96  describe('loadLock', () => {
 97    it('returns null when skills-lock.json is missing', () => {
 98      assert.equal(loadLock(tmp), null);
 99    });
100
101    it('parses skills-lock.json when present', () => {
102      const lock = { version: 1, skills: { arrange: { source: 'pbakaus/impeccable' } } };
103      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
104      assert.deepEqual(loadLock(tmp), lock);
105    });
106
107    it('returns null on malformed JSON', () => {
108      writeFileSync(join(tmp, 'skills-lock.json'), '{not json', 'utf-8');
109      assert.equal(loadLock(tmp), null);
110    });
111  });
112
113  describe('buildTargetNames', () => {
114    it('includes both unprefixed and i-prefixed names', () => {
115      const names = buildTargetNames();
116      assert.ok(names.includes('arrange'));
117      assert.ok(names.includes('i-arrange'));
118      assert.ok(names.includes('frontend-design'));
119      assert.ok(names.includes('i-frontend-design'));
120      assert.equal(names.length, 46); // 23 deprecated * 2
121    });
122  });
123
124  describe('findSkillsDirs', () => {
125    it('finds existing harness skill directories', () => {
126      mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
127      mkdirSync(join(tmp, '.agents', 'skills'), { recursive: true });
128      const dirs = findSkillsDirs(tmp);
129      assert.equal(dirs.length, 2);
130    });
131
132    it('ignores non-existent harness directories', () => {
133      const dirs = findSkillsDirs(tmp);
134      assert.equal(dirs.length, 0);
135    });
136  });
137
138  describe('removeDeprecatedSkills', () => {
139    it('deletes impeccable-owned deprecated skill directories', () => {
140      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
141      writeSkill(tmp, '.claude', 'normalize', 'Run impeccable teach.');
142      const deleted = removeDeprecatedSkills(tmp);
143      assert.equal(deleted.length, 2);
144      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
145      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'normalize')), false);
146    });
147
148    it('does NOT delete skills that do not mention impeccable', () => {
149      writeSkill(tmp, '.claude', 'arrange', 'My custom layout organizer.');
150      const deleted = removeDeprecatedSkills(tmp);
151      assert.equal(deleted.length, 0);
152      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), true);
153    });
154
155    it('deletes i-prefixed variants', () => {
156      writeSkill(tmp, '.cursor', 'i-normalize', 'Invoke /impeccable first.');
157      const deleted = removeDeprecatedSkills(tmp);
158      assert.equal(deleted.length, 1);
159      assert.equal(existsSync(join(tmp, '.cursor', 'skills', 'i-normalize')), false);
160    });
161
162    it('cleans across multiple harness directories', () => {
163      writeSkill(tmp, '.claude', 'onboard', 'Run impeccable teach first.');
164      writeSkill(tmp, '.agents', 'onboard', 'Run impeccable teach first.');
165      writeSkill(tmp, '.cursor', 'onboard', 'Run impeccable teach first.');
166      const deleted = removeDeprecatedSkills(tmp);
167      assert.equal(deleted.length, 3);
168    });
169
170    it('leaves non-deprecated skills alone', () => {
171      writeSkill(tmp, '.claude', 'my-custom-skill', 'Invoke /impeccable first.');
172      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable first.');
173      const deleted = removeDeprecatedSkills(tmp);
174      assert.equal(deleted.length, 1); // only arrange
175      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'my-custom-skill')), true);
176    });
177
178    it('deletes a stock v2.x harden skill with no lock file via the fingerprint fallback', () => {
179      // Reproduces the no-lock-file install path: user installed via
180      // submodule or manual copy, so skills-lock.json never existed. The
181      // v2.x harden SKILL.md never contained the word "impeccable", but
182      // does contain the distinctive description fingerprint.
183      const body = [
184        '---',
185        'name: harden',
186        'description: "Make interfaces production-ready: error handling, empty states, onboarding flows, i18n, text overflow, and edge case management."',
187        '---',
188        '',
189        'Strengthen interfaces against edge cases.',
190      ].join('\n');
191      writeSkill(tmp, '.claude', 'harden', body);
192      const deleted = removeDeprecatedSkills(tmp);
193      assert.equal(deleted.length, 1);
194      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), false);
195    });
196
197    it('deletes a stock v2.x optimize skill with no lock file via the fingerprint fallback', () => {
198      const body = [
199        '---',
200        'name: optimize',
201        'description: "Diagnoses and fixes UI performance across loading speed, rendering, animations, images, and bundle size."',
202        '---',
203        '',
204        'Identify and fix performance issues.',
205      ].join('\n');
206      writeSkill(tmp, '.claude', 'optimize', body);
207      const deleted = removeDeprecatedSkills(tmp);
208      assert.equal(deleted.length, 1);
209    });
210
211    it('does NOT delete a user-written harden skill that lacks both the pack word and the fingerprint', () => {
212      writeSkill(tmp, '.claude', 'harden', 'My custom skill for hardening cookies against CSRF.');
213      const deleted = removeDeprecatedSkills(tmp);
214      assert.equal(deleted.length, 0);
215      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), true);
216    });
217
218    it('deletes skills whose SKILL.md never mentions impeccable when the lock claims them', () => {
219      // Reproduces the "orphan dir" bug: the old SKILL.md bodies described
220      // each skill on its own merits and never said the word "impeccable",
221      // so the content heuristic returned false. The lock source is the
222      // authoritative signal.
223      writeSkill(tmp, '.claude', 'harden', '# Harden\n\nA custom skill with zero pack-name mentions.');
224      const lock = {
225        version: 1,
226        skills: { harden: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'x' } },
227      };
228      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
229      const deleted = removeDeprecatedSkills(tmp);
230      assert.equal(deleted.length, 1);
231      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'harden')), false);
232    });
233
234    it('does NOT delete a same-named skill owned by a different pack', () => {
235      writeSkill(tmp, '.claude', 'extract', 'Some user-written extract skill.');
236      const lock = {
237        version: 1,
238        skills: { extract: { source: 'someone-else/pack' } },
239      };
240      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
241      const deleted = removeDeprecatedSkills(tmp);
242      assert.equal(deleted.length, 0);
243      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'extract')), true);
244    });
245
246    it('handles symlinks to deprecated skills', () => {
247      // Create the canonical skill in .agents
248      const canonical = writeSkill(tmp, '.agents', 'extract', 'Use impeccable extract.');
249      // Create a symlink in .claude
250      mkdirSync(join(tmp, '.claude', 'skills'), { recursive: true });
251      symlinkSync(canonical, join(tmp, '.claude', 'skills', 'extract'));
252      const deleted = removeDeprecatedSkills(tmp);
253      assert.equal(deleted.length, 2); // both canonical and symlink
254    });
255  });
256
257  describe('cleanSkillsLock', () => {
258    it('removes impeccable-owned deprecated entries', () => {
259      const lock = {
260        version: 1,
261        skills: {
262          arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
263          impeccable: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
264          'resolve-reviews': { source: 'pbakaus/agent-reviews', sourceType: 'github', computedHash: 'ghi' },
265        },
266      };
267      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
268      const removed = cleanSkillsLock(tmp);
269      assert.deepEqual(removed, ['arrange']);
270      const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
271      assert.equal(updated.skills.arrange, undefined);
272      assert.ok(updated.skills.impeccable); // not deprecated
273      assert.ok(updated.skills['resolve-reviews']); // different source
274    });
275
276    it('does NOT remove entries from other sources', () => {
277      const lock = {
278        version: 1,
279        skills: {
280          extract: { source: 'some-other/package', sourceType: 'github', computedHash: 'xyz' },
281        },
282      };
283      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
284      const removed = cleanSkillsLock(tmp);
285      assert.equal(removed.length, 0);
286    });
287
288    it('handles missing skills-lock.json gracefully', () => {
289      const removed = cleanSkillsLock(tmp);
290      assert.equal(removed.length, 0);
291    });
292
293    it('removes i-prefixed entries', () => {
294      const lock = {
295        version: 1,
296        skills: {
297          'i-arrange': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'abc' },
298          'i-normalize': { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'def' },
299        },
300      };
301      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
302      const removed = cleanSkillsLock(tmp);
303      assert.equal(removed.length, 2);
304    });
305  });
306
307  describe('cleanup (integration)', () => {
308    it('cleans both files and lock entries in one pass', () => {
309      // Set up deprecated skills in two harness dirs
310      writeSkill(tmp, '.claude', 'arrange', 'Invoke /impeccable.');
311      writeSkill(tmp, '.agents', 'arrange', 'Invoke /impeccable.');
312      writeSkill(tmp, '.claude', 'extract', 'Run impeccable extract.');
313
314      // Set up lock file
315      const lock = {
316        version: 1,
317        skills: {
318          arrange: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'a' },
319          extract: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'b' },
320          impeccable: { source: 'pbakaus/impeccable', sourceType: 'github', computedHash: 'c' },
321        },
322      };
323      writeFileSync(join(tmp, 'skills-lock.json'), JSON.stringify(lock), 'utf-8');
324
325      const result = cleanup(tmp);
326      assert.equal(result.deletedPaths.length, 3);
327      assert.equal(result.removedLockEntries.length, 2); // arrange + extract
328      assert.equal(existsSync(join(tmp, '.claude', 'skills', 'arrange')), false);
329      assert.equal(existsSync(join(tmp, '.agents', 'skills', 'arrange')), false);
330
331      const updated = JSON.parse(readFileSync(join(tmp, 'skills-lock.json'), 'utf-8'));
332      assert.ok(updated.skills.impeccable); // not deprecated
333      assert.equal(updated.skills.arrange, undefined);
334      assert.equal(updated.skills.extract, undefined);
335    });
336
337    it('is a no-op when nothing needs cleaning', () => {
338      writeSkill(tmp, '.claude', 'my-custom-skill', 'Invoke /impeccable.');
339      const result = cleanup(tmp);
340      assert.equal(result.deletedPaths.length, 0);
341      assert.equal(result.removedLockEntries.length, 0);
342    });
343  });
344});