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:
parent
775107776d
commit
4fa00d1cfd
2 changed files with 557 additions and 51 deletions
|
|
@ -247,7 +247,8 @@ export type SkillIssueCode =
|
|||
| "size-limit-exceeded"
|
||||
| "frontmatter-absent"
|
||||
| "frontmatter-unparsable"
|
||||
| "frontmatter-mismatch";
|
||||
| "frontmatter-mismatch"
|
||||
| "duplicate-name";
|
||||
|
||||
/**
|
||||
* `error` marks a **MUST** of SEP-2640 that the server broke, so a manifest
|
||||
|
|
@ -836,3 +837,68 @@ export function checkSkillFrontmatterMatch(
|
|||
}
|
||||
return issues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Findings that can only be computed over the **whole listing**, keyed by the
|
||||
* entry they belong to (its normalized URI identity).
|
||||
*
|
||||
* Today that is exactly one: two entries in a single `skills/list` colliding on
|
||||
* `frontmatter.name`. {@link checkSkillConformance} structurally cannot report
|
||||
* it — it sees one entry at a time, and a collision is a property of the pair.
|
||||
*
|
||||
* SEP-2640: *"Hosts MUST NOT assume name uniqueness"*, and *"When two entries
|
||||
* in one listing collide on `name`, hosts MUST disambiguate them — for example
|
||||
* by their distinguishing path segments — rather than silently discarding or
|
||||
* preferring one."*
|
||||
*
|
||||
* ⚠️ **A collision is a `warning`, not an `error`, and the distinction is the
|
||||
* whole point of the severity split.** The obligation here is on the *host*,
|
||||
* not the server: a server may legitimately publish two skills with the same
|
||||
* name under different paths, and the SEP's own example
|
||||
* (`acme/billing/refunds`) is exactly that. Reporting it as an error would tell
|
||||
* a conforming server author their catalog is invalid. What the warning says is
|
||||
* that a consumer must not collapse the two — which is why the Inspector shows
|
||||
* each skill's URI beside its name, and now says so rather than leaving the
|
||||
* reader to notice.
|
||||
*
|
||||
* Names are compared **raw**, not trimmed or case-folded. The Agent Skills
|
||||
* grammar is lowercase already, and a checker that normalized more than the
|
||||
* grammar does would report a collision between two names the spec considers
|
||||
* distinct.
|
||||
*/
|
||||
export function checkSkillNameCollisions(
|
||||
entries: readonly SkillEntry[],
|
||||
): Map<string, SkillIssue> {
|
||||
const byName = new Map<string, SkillEntry[]>();
|
||||
for (const entry of entries) {
|
||||
const name = entry.frontmatter.name;
|
||||
// An absent name is `missing-name`, reported per entry. Two entries that
|
||||
// both omit one are not "colliding on a name" — there is no name — and
|
||||
// saying so would bury the real finding under a derived one.
|
||||
if (typeof name !== "string" || name.trim() === "") continue;
|
||||
const group = byName.get(name);
|
||||
if (group) group.push(entry);
|
||||
else byName.set(name, [entry]);
|
||||
}
|
||||
|
||||
const issues = new Map<string, SkillIssue>();
|
||||
for (const [name, group] of byName) {
|
||||
// Deduplicated by URI identity first: the SAME skill appearing twice in a
|
||||
// listing is a repeated entry, not two skills sharing a name, and
|
||||
// `skills/list` returning it twice is a different defect from the one this
|
||||
// function reports.
|
||||
const identities = new Set(group.map((e) => skillUriIdentity(e.uri)));
|
||||
if (identities.size < 2) continue;
|
||||
const uris = [...identities].sort();
|
||||
for (const entry of group) {
|
||||
const self = skillUriIdentity(entry.uri);
|
||||
const others = uris.filter((uri) => uri !== self);
|
||||
issues.set(self, {
|
||||
code: "duplicate-name",
|
||||
severity: "warning",
|
||||
message: `Another skill in this listing also declares the name "${name}" (${others.join(", ")}). This is legal — a consumer must tell them apart by their URIs rather than collapsing or preferring one.`,
|
||||
});
|
||||
}
|
||||
}
|
||||
return issues;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,6 +30,7 @@ import type { RequestMetadata } from "./types.js";
|
|||
import {
|
||||
checkSkillConformance,
|
||||
checkSkillFrontmatterMatch,
|
||||
checkSkillNameCollisions,
|
||||
skillDisplayName,
|
||||
skillFileBytes,
|
||||
skillUriIdentity,
|
||||
|
|
@ -141,6 +142,12 @@ export async function verifySkills(
|
|||
entries: readonly SkillEntry[],
|
||||
metadata?: RequestMetadata,
|
||||
): Promise<SkillVerifyReport[]> {
|
||||
// Computed once over the whole set, because a name collision is a property
|
||||
// of the listing rather than of an entry — `checkSkillConformance` sees one
|
||||
// at a time and structurally cannot report it. Note this is scoped to the
|
||||
// entries passed in, so `--method skills/get --verify` on a single skill
|
||||
// reports no collision: there is no listing to collide within.
|
||||
const collisions = checkSkillNameCollisions(entries);
|
||||
const reports: SkillVerifyReport[] = [];
|
||||
for (const entry of entries) {
|
||||
// The entry's own SKILL.md, read once and used twice — for its digest and
|
||||
|
|
@ -211,7 +218,11 @@ export async function verifySkills(
|
|||
}
|
||||
}
|
||||
|
||||
const conformance = checkSkillConformance(entry);
|
||||
const collision = collisions.get(skillUriIdentity(entry.uri));
|
||||
const conformance = [
|
||||
...checkSkillConformance(entry),
|
||||
...(collision ? [collision] : []),
|
||||
];
|
||||
const frontmatter =
|
||||
entryText === undefined
|
||||
? []
|
||||
|
|
|
|||
Loading…
Reference in a new issue