Skip to content

Commit ca6dbf2

Browse files
authored
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.
1 parent b6ba0ec commit ca6dbf2

3 files changed

Lines changed: 59 additions & 0 deletions

File tree

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ Exit codes:
9898
| `name-drift` | warn | Frontmatter `name:` doesn't match the filename or directory |
9999
| `description-collision` | warn | Two skills' descriptions have Jaccard ≥ 0.6 |
100100
| `tools-overloaded` | warn | `tools:` lists 10 or more entries; narrow the list to what this skill actually needs |
101+
| `duplicate-name` | warn | Two or more skills share the same `name:` value; resolution is ambiguous |
101102
| `parse` | error | The file doesn't have valid frontmatter / YAML |
102103

103104
The MCP and built-in tool checks read `~/.claude/settings.json` and

src/checks.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ export function runChecks(
3636
}
3737

3838
diagnostics.push(...checkCollisions(validated));
39+
diagnostics.push(...checkDuplicateNames(validated));
3940
return diagnostics;
4041
}
4142

@@ -206,3 +207,30 @@ function checkCollisions(skills: ValidatedSkill[]): Diagnostic[] {
206207
}
207208
return out;
208209
}
210+
211+
function checkDuplicateNames(skills: ValidatedSkill[]): Diagnostic[] {
212+
const byName = new Map<string, ValidatedSkill[]>();
213+
for (const s of skills) {
214+
const key = s.name.toLowerCase();
215+
const group = byName.get(key) ?? [];
216+
group.push(s);
217+
byName.set(key, group);
218+
}
219+
const out: Diagnostic[] = [];
220+
for (const group of byName.values()) {
221+
if (group.length < 2) continue;
222+
for (const s of group) {
223+
const others = group
224+
.filter((g) => g.file !== s.file)
225+
.map((g) => `'${g.file}'`)
226+
.join(", ");
227+
out.push({
228+
severity: "warn",
229+
rule: "duplicate-name",
230+
message: `skill name '${s.name}' is also declared in ${others}`,
231+
file: s.file,
232+
});
233+
}
234+
}
235+
return out;
236+
}

test/checks.test.ts

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -234,4 +234,34 @@ describe("runChecks", () => {
234234
const ds = runChecks([s], config);
235235
expect(ds.find((d) => d.rule === "empty-body")).toBeUndefined();
236236
});
237+
238+
it("flags duplicate skill names", () => {
239+
const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" });
240+
const b = mkSkill("/test/b/deploy.md", { name: "deploy", description: "deploy to staging" });
241+
const ds = runChecks([a, b], config);
242+
expect(ds.filter((d) => d.rule === "duplicate-name").length).toBe(2);
243+
});
244+
245+
it("does not flag duplicate-name for unique names", () => {
246+
const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" });
247+
const b = mkSkill("/test/b/release.md", { name: "release", description: "cut a release" });
248+
const ds = runChecks([a, b], config);
249+
expect(ds.find((d) => d.rule === "duplicate-name")).toBeUndefined();
250+
});
251+
252+
it("flags all skills in a three-way duplicate-name group", () => {
253+
const a = mkSkill("/test/a/foo.md", { name: "foo", description: "do foo one way" });
254+
const b = mkSkill("/test/b/foo.md", { name: "foo", description: "do foo another way" });
255+
const c = mkSkill("/test/c/foo.md", { name: "foo", description: "do foo a third way" });
256+
const ds = runChecks([a, b, c], config);
257+
expect(ds.filter((d) => d.rule === "duplicate-name").length).toBe(3);
258+
});
259+
260+
it("duplicate-name message names the conflicting file", () => {
261+
const a = mkSkill("/test/a/deploy.md", { name: "deploy", description: "deploy the app" });
262+
const b = mkSkill("/test/b/deploy.md", { name: "deploy", description: "deploy to staging" });
263+
const ds = runChecks([a, b], config);
264+
const diagA = ds.find((d) => d.rule === "duplicate-name" && d.file === "/test/a/deploy.md");
265+
expect(diagA?.message).toContain("/test/b/deploy.md");
266+
});
237267
});

0 commit comments

Comments
 (0)