diff --git a/README.md b/README.md index b766d01..08f4567 100644 --- a/README.md +++ b/README.md @@ -98,6 +98,7 @@ Exit codes: | `name-drift` | warn | Frontmatter `name:` doesn't match the filename or directory | | `description-collision` | warn | Two skills' descriptions have Jaccard ≥ 0.6 | | `tools-overloaded` | warn | `tools:` lists 10 or more entries; narrow the list to what this skill actually needs | +| `duplicate-name` | warn | Two or more skills share the same `name:` value; resolution is ambiguous | | `parse` | error | The file doesn't have valid frontmatter / YAML | The MCP and built-in tool checks read `~/.claude/settings.json` and diff --git a/src/checks.ts b/src/checks.ts index a516893..034e969 100644 --- a/src/checks.ts +++ b/src/checks.ts @@ -36,6 +36,7 @@ export function runChecks( } diagnostics.push(...checkCollisions(validated)); + diagnostics.push(...checkDuplicateNames(validated)); return diagnostics; } @@ -206,3 +207,30 @@ function checkCollisions(skills: ValidatedSkill[]): Diagnostic[] { } return out; } + +function checkDuplicateNames(skills: ValidatedSkill[]): Diagnostic[] { + const byName = new Map(); + for (const s of skills) { + const key = s.name.toLowerCase(); + const group = byName.get(key) ?? []; + group.push(s); + byName.set(key, group); + } + const out: Diagnostic[] = []; + for (const group of byName.values()) { + if (group.length < 2) continue; + for (const s of group) { + const others = group + .filter((g) => g.file !== s.file) + .map((g) => `'${g.file}'`) + .join(", "); + out.push({ + severity: "warn", + rule: "duplicate-name", + message: `skill name '${s.name}' is also declared in ${others}`, + file: s.file, + }); + } + } + return out; +} diff --git a/test/checks.test.ts b/test/checks.test.ts index 8c0258b..4474ae7 100644 --- a/test/checks.test.ts +++ b/test/checks.test.ts @@ -234,4 +234,34 @@ describe("runChecks", () => { const ds = runChecks([s], config); expect(ds.find((d) => d.rule === "empty-body")).toBeUndefined(); }); + + it("flags duplicate skill names", () => { + const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" }); + const b = mkSkill("/test/b/deploy.md", { name: "deploy", description: "deploy to staging" }); + const ds = runChecks([a, b], config); + expect(ds.filter((d) => d.rule === "duplicate-name").length).toBe(2); + }); + + it("does not flag duplicate-name for unique names", () => { + const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" }); + const b = mkSkill("/test/b/release.md", { name: "release", description: "cut a release" }); + const ds = runChecks([a, b], config); + expect(ds.find((d) => d.rule === "duplicate-name")).toBeUndefined(); + }); + + it("flags all skills in a three-way duplicate-name group", () => { + const a = mkSkill("/test/a/foo.md", { name: "foo", description: "do foo one way" }); + const b = mkSkill("/test/b/foo.md", { name: "foo", description: "do foo another way" }); + const c = mkSkill("/test/c/foo.md", { name: "foo", description: "do foo a third way" }); + const ds = runChecks([a, b, c], config); + expect(ds.filter((d) => d.rule === "duplicate-name").length).toBe(3); + }); + + it("duplicate-name message names the conflicting file", () => { + const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" }); + const b = mkSkill("/test/b/deploy.md", { name: "deploy", description: "deploy to staging" }); + const ds = runChecks([a, b], config); + const diagA = ds.find((d) => d.rule === "duplicate-name" && d.file === "/test/a/deploy.md"); + expect(diagA?.message).toContain("/test/b/deploy.md"); + }); });