Ctrl K
ComunidadGuías…

test/skill-duplicates.test.js

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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);
  });
});