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