test/skill-duplicates.test.js
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'fs';
import { join } from 'path';
import { tmpdir } from 'os';
import { collectSkills, findDuplicates, duplicateNotice, reportDuplicates, forgetReportedDuplicates } from '../server/skill-duplicates.js';
import { removeTempDir } from './temp-dir.js';
let root, claudeDir, agentsDir, repo;
const skill = (dir, name, description = 'Does a thing.', body = 'body') => {
mkdirSync(dir, { recursive: true });
writeFileSync(join(dir, 'SKILL.md'), `---\nname: ${name}\ndescription: ${description}\n---\n${body}\n`);
};
const find = () => findDuplicates(collectSkills({ claudeDir, agentsDir }, [repo]), agentsDir);
beforeEach(() => {
root = mkdtempSync(join(tmpdir(), 'a007-dups-'));
claudeDir = join(root, '.claude');
agentsDir = join(root, '.agents');
repo = join(root, 'repo');
mkdirSync(join(claudeDir, 'skills'), { recursive: true });
forgetReportedDuplicates();
});
afterEach(() => removeTempDir(root));
describe('duplicate skills', () => {
it('finds the same name in two places, and whether the copies match', () => {
skill(join(claudeDir, 'skills', 'pdf'), 'pdf');
skill(join(repo, '.claude', 'skills', 'pdf'), 'pdf');
skill(join(claudeDir, 'skills', 'xlsx'), 'xlsx', 'one');
skill(join(agentsDir, 'skills', 'xlsx'), 'xlsx', 'two');
const f = find();
expect(f.map(x => x.kind)).toEqual([
'the same skill name "pdf", and the two copies are byte-identical',
'the same skill name "xlsx", with different contents',
]);
expect(f[0].remove.dir).toBe(join(repo, '.claude', 'skills', 'pdf'));
expect(duplicateNotice(f[0]).lines.join('\n')).toMatch(/open a PR/);
expect(duplicateNotice(f[1]).lines.join('\n')).toMatch(/npx skills remove xlsx/);
});
it('does not pair skills from two different repos, but still pairs a repo with a global one', () => {
const other = join(root, 'other');
skill(join(repo, '.claude', 'skills', 'qa'), 'qa', 'one');
skill(join(other, '.claude', 'skills', 'qa'), 'qa', 'two');
const both = () => findDuplicates(collectSkills({ claudeDir, agentsDir }, [repo, other]), agentsDir);
expect(both()).toEqual([]);
skill(join(claudeDir, 'skills', 'qa'), 'qa', 'three');
expect(both().map(x => x.paths[1])).toEqual([join(repo, '.claude', 'skills', 'qa'), join(other, '.claude', 'skills', 'qa')]);
});
it('skips one installer-managed skill mirrored into ~/.claude and ~/.agents, but not drift or unlocked copies', () => {
mkdirSync(agentsDir, { recursive: true });
writeFileSync(join(agentsDir, '.skill-lock.json'), JSON.stringify({ skills: { locked: {}, locked2: {}, locked3: {}, drifted: {} } }));
for (const n of ['locked', 'locked2', 'locked3', 'unlocked']) for (const h of [claudeDir, agentsDir]) skill(join(h, 'skills', n), n);
skill(join(claudeDir, 'skills', 'drifted'), 'drifted', 'one');
skill(join(agentsDir, 'skills', 'drifted'), 'drifted', 'two');
expect(find().map(x => x.kind)).toEqual([
'the same skill name "drifted", with different contents',
'the same skill name "unlocked", and the two copies are byte-identical',
]);
});
it('finds byte-identical folders under different names, but not one skill linked twice', () => {
skill(join(agentsDir, 'skills', 'a'), 'alpha');
symlinkSync(join(agentsDir, 'skills', 'a'), join(claudeDir, 'skills', 'linked'), 'dir');
expect(find()).toEqual([]);
// No name line, so each is named by its folder; the bytes match.
for (const d of ['x1', 'x2']) {
mkdirSync(join(claudeDir, 'skills', d));
writeFileSync(join(claudeDir, 'skills', d, 'SKILL.md'), '---\ndescription: same\n---\nsame body\n');
}
expect(find().map(x => x.kind)).toEqual(['byte-identical folders under different names ("x1" and "x2")']);
});
it('finds a skill that says it replaces another installed one', () => {
skill(join(claudeDir, 'skills', 'old-qa'), 'old-qa', 'Test things.');
skill(join(claudeDir, 'skills', 'new-qa'), 'new-qa', 'Replaces old-qa with a faster run.');
skill(join(claudeDir, 'skills', 'stale'), 'stale', 'Deprecated, use fresh instead.');
skill(join(claudeDir, 'skills', 'fresh'), 'fresh', 'The current one.');
skill(join(claudeDir, 'skills', 'solo'), 'solo', 'Replaces nothing-installed.');
const f = find();
expect(f.map(x => x.kind).sort()).toEqual(['"new-qa" says it replaces "old-qa"', '"stale" says it is replaced by "fresh"']);
expect(f.map(x => x.remove.name).sort()).toEqual(['old-qa', 'stale']);
});
it('leaves gstack\'s intentional aliases alone', () => {
skill(join(claudeDir, 'skills', 'gstack'), 'gstack');
skill(join(claudeDir, 'skills', '_gstack-command'), 'gstack');
skill(join(claudeDir, 'skills', 'connect-chrome'), 'connect-chrome', 'Launch.');
skill(join(claudeDir, 'skills', 'gstack-connect-chrome'), 'gstack-connect-chrome', 'Launch.');
expect(find()).toEqual([]);
});
it('tells Billion once per finding, and never deletes', () => {
skill(join(claudeDir, 'skills', 'pdf'), 'pdf');
skill(join(repo, '.claude', 'skills', 'pdf'), 'pdf');
const sent = [];
const send = (_s, headline, lines) => { sent.push(lines[0]); return true; };
const skills = collectSkills({ claudeDir, agentsDir }, [repo]);
expect(reportDuplicates(null, send, skills, {})).toBe(0);
expect(reportDuplicates({ id: 'b' }, send, skills, {})).toBe(1);
expect(reportDuplicates({ id: 'b' }, send, skills, {})).toBe(0);
expect(reportDuplicates({ id: 'b' }, send, skills, { SKILL_FAMILIES: '0' })).toBe(0);
expect(sent[0]).toContain(join(repo, '.claude', 'skills', 'pdf'));
expect(collectSkills({ claudeDir, agentsDir }, [repo])).toHaveLength(2);
});
});