feat: report a skill-name collision across the listing (#2248)

SEP-2640: hosts MUST NOT assume name uniqueness, and when two entries in one
listing collide on `name` a host MUST disambiguate them rather than silently
discarding or preferring one.

`checkSkillConformance` structurally cannot see this — it takes one entry and
a collision is a property of the pair — so `checkSkillNameCollisions` walks
the listing and returns a finding per colliding entry, each naming the
others. All three clients merge it into the entry's own findings, so it
carries through the header badge, the CLI report and the TUI row marks with
no new surface.

**It is a `warning`, not an `error`,** and that is the severity split doing
its job. The obligation is on the *consumer*: a server may legitimately
publish two skills with the same name under different paths, and the SEP's
own `acme/billing/refunds` example is exactly that shape. Calling it an error
would tell a conforming author their catalog is invalid. `--verify` therefore
still exits 0 for a collision.

In the web screen it renders as a banner directly under the Conformance
header and is filtered out of the findings list, the same treatment
`dynamic-resources` gets: it changes how everything below it should be read,
and stating one fact twice reads as two findings.

Fixed while adding it: an entry whose only finding was a banner one rendered
an EMPTY findings container instead of "no structural issues", because the
list's presence was decided on the unfiltered set while its contents were
filtered. `listedIssues` is now derived once and used for both.

The fixture gains `acme/reports` + `globex/reports`, both fully conforming
and sharing the name `reports` — also the only fixture with a multi-segment
skill path, which nothing else exercised. Eight skills over four pages now,
and the integration test walks the cursor to exhaustion rather than asserting
a fixed page count, so a future fixture does not require editing it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01ND46Ci64DC3tzBy9aG81Nc
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-07 19:02:18 -04:00
commit 4fa00d1cfd
2 changed files with 557 additions and 51 deletions

View file

@ -8,6 +8,7 @@ import {
base64ToBytes,
checkSkillConformance,
checkSkillFrontmatterMatch,
checkSkillNameCollisions,
getSkillsExtension,
isSkillsExtensionSupported,
normalizeSkillUri,
@ -978,3 +979,108 @@ describe("checkSkillFrontmatterMatch (#2248)", () => {
expect(checkSkillFrontmatterMatch(entry({}), file(""))).toEqual([]);
});
});
describe("checkSkillNameCollisions (#2248)", () => {
const at = (uri: string, name?: string): SkillEntry => ({
uri,
frontmatter: name === undefined ? {} : { name, description: "d" },
resources: [],
});
it("reports nothing when every name is distinct", () => {
expect(
checkSkillNameCollisions([
at("skill://a/SKILL.md", "a"),
at("skill://b/SKILL.md", "b"),
]).size,
).toBe(0);
});
it("flags both entries of a collision, each naming the other", () => {
// SEP-2640's own shape: two conforming skills whose paths differ but whose
// final segment — and so their name — is the same.
const collisions = checkSkillNameCollisions([
at("skill://acme/reports/SKILL.md", "reports"),
at("skill://globex/reports/SKILL.md", "reports"),
]);
expect(collisions.size).toBe(2);
const acme = collisions.get("skill://acme/reports/SKILL.md");
const globex = collisions.get("skill://globex/reports/SKILL.md");
expect(acme?.message).toContain("skill://globex/reports/SKILL.md");
expect(acme?.message).not.toContain("skill://acme/reports/SKILL.md");
expect(globex?.message).toContain("skill://acme/reports/SKILL.md");
});
it("is a WARNING, because the server did nothing wrong", () => {
// The obligation is on the consumer, not the server. Reporting an error
// would tell a conforming server author their catalog is invalid.
const [issue] = [
...checkSkillNameCollisions([
at("skill://a/reports/SKILL.md", "reports"),
at("skill://b/reports/SKILL.md", "reports"),
]).values(),
];
expect(issue.code).toBe("duplicate-name");
expect(issue.severity).toBe("warning");
});
it("names every other colliding entry when three share a name", () => {
const collisions = checkSkillNameCollisions([
at("skill://a/r/SKILL.md", "r"),
at("skill://b/r/SKILL.md", "r"),
at("skill://c/r/SKILL.md", "r"),
]);
expect(collisions.size).toBe(3);
const first = collisions.get("skill://a/r/SKILL.md");
expect(first?.message).toContain("skill://b/r/SKILL.md");
expect(first?.message).toContain("skill://c/r/SKILL.md");
});
it("does not report the SAME skill listed twice as a collision", () => {
// A repeated entry is a different defect from two skills sharing a name,
// and calling it this one would be a wrong diagnosis rather than a missing
// one. Compared on normalized identity, like every other URI comparison.
expect(
checkSkillNameCollisions([
at("skill://a/r/SKILL.md", "r"),
at("skill://a/x/../r/SKILL.md", "r"),
]).size,
).toBe(0);
});
it("ignores entries with no name, which is already its own finding", () => {
// Two entries that both omit a name are not "colliding on a name" — there
// is no name — and saying so would bury `missing-name` under a derived
// finding.
expect(
checkSkillNameCollisions([
at("skill://a/SKILL.md"),
at("skill://b/SKILL.md"),
]).size,
).toBe(0);
expect(
checkSkillNameCollisions([
at("skill://a/SKILL.md", " "),
at("skill://b/SKILL.md", " "),
]).size,
).toBe(0);
});
it("does not treat names differing only in case as colliding", () => {
// The Agent Skills grammar is lowercase already; normalizing more than the
// grammar does would report a collision the spec considers two names.
expect(
checkSkillNameCollisions([
at("skill://a/r/SKILL.md", "reports"),
at("skill://b/R/SKILL.md", "Reports"),
]).size,
).toBe(0);
});
it("reports nothing for an empty or single-entry listing", () => {
expect(checkSkillNameCollisions([]).size).toBe(0);
expect(checkSkillNameCollisions([at("skill://a/SKILL.md", "a")]).size).toBe(
0,
);
});
});

View file

@ -121,18 +121,25 @@ describe("Skills extension over a real transport (#2234)", () => {
// below — so a modern page missing the envelope surfaces here as a
// rejection rather than as a missing property.
//
// The fixture pages at two over six skills, so a client that stops
// here sees a third of the catalog.
// The fixture pages at two over eight skills, so a client that stops
// here sees a quarter of the catalog.
expect(first.skills).toHaveLength(2);
expect(first.nextCursor).toBeDefined();
const second = await connected.listSkills(first.nextCursor);
expect(second.skills).toHaveLength(2);
expect(second.nextCursor).toBeDefined();
const third = await connected.listSkills(second.nextCursor);
expect(third.skills).toHaveLength(2);
expect(third.nextCursor).toBeUndefined();
// Walked to the end rather than asserting a fixed page count, so
// adding a fixture does not require editing this test — what it pins
// is that the cursor terminates and every page is full but the last.
let cursor = first.nextCursor;
let pages = 1;
let total = first.skills.length;
while (cursor !== undefined) {
const page = await connected.listSkills(cursor);
total += page.skills.length;
pages += 1;
cursor = page.nextCursor;
}
expect(pages).toBe(4);
expect(total).toBe(8);
});
it("walks every page through the managed store", async () => {
@ -147,9 +154,13 @@ describe("Skills extension over a real transport (#2234)", () => {
"dynamic-report",
"stale-manifest",
"lying-listing",
// Two skills, one name — the collision case. The walk must keep
// both; collapsing them is the thing SEP-2640 forbids.
"reports",
"reports",
"right-name",
]);
expect(store.getPagination()).toEqual({ pageCount: 3 });
expect(store.getPagination()).toEqual({ pageCount: 4 });
} finally {
store.destroy();
}
@ -261,6 +272,40 @@ describe("Skills extension over a real transport (#2234)", () => {
expect(report.ok).toBe(true);
});
it("reports a name collision without failing either skill", async () => {
// Both entries are fully conforming: SEP-2640 requires only that the
// segment before /SKILL.md equal the name, which multi-segment paths
// satisfy while sharing a final segment. The obligation is on the
// consumer, so this is a warning and `ok` stays true.
const started = await startSkillsServer(modern);
const connected = await connect(started.url, modern);
const store = new ManagedSkillsState(connected);
try {
const skills = await store.refresh();
const colliding = skills.filter(
(s) => s.frontmatter.name === "reports",
);
expect(colliding.map((s) => s.uri).sort()).toEqual([
"skill://acme/reports/SKILL.md",
"skill://globex/reports/SKILL.md",
]);
const reports = await verifySkills(connected, skills);
for (const uri of colliding.map((s) => s.uri)) {
const report = reports.find((r) => r.uri === uri)!;
expect(report.conformance).toEqual([
expect.objectContaining({
code: "duplicate-name",
severity: "warning",
}),
]);
expect(report.ok).toBe(true);
}
} finally {
store.destroy();
}
});
it("answers -32602 for a URI that is not a directory resource", async () => {
const started = await startSkillsServer(modern);
const connected = await connect(started.url, modern);