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});