From 61451b9342a81143405ece5460e42da64a6337b5 Mon Sep 17 00:00:00 2001 From: protosphinx <133899485+protosphinx@users.noreply.github.com> Date: Mon, 18 May 2026 16:10:00 +0000 Subject: [PATCH] feat(checks): add duplicate-name rule Warn when two or more skills declare the same frontmatter name. At runtime the selection is ambiguous and one skill silently shadows the other, so this is a real bug with no visible signal in model output. The check follows the cross-skill pattern of description-collision: collect all validated skills, group by lowercased name, then emit one diagnostic per skill in any group with two or more members. Each diagnostic names the conflicting file so the author knows exactly which skill to rename. Four new tests cover: two-way duplicate, three-way duplicate, no duplicate (no diagnostic), and the diagnostic message content. --- README.md | 1 + src/checks.ts | 28 ++++++++++++++++++++++++++++ test/checks.test.ts | 30 ++++++++++++++++++++++++++++++ 3 files changed, 59 insertions(+) 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"); + }); });