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
|
|
@ -602,6 +602,51 @@ describe("SkillsTab (#2248)", () => {
|
|||
expect(lastFrame() ?? "").toContain("nameless");
|
||||
});
|
||||
|
||||
it("reports a name collision on both entries, as a warning", async () => {
|
||||
// A catalog-level fact `checkSkillConformance` structurally cannot see —
|
||||
// and a warning, because the server did nothing wrong: the obligation is
|
||||
// on the consumer to tell two same-named skills apart.
|
||||
const acme: SkillEntry = {
|
||||
uri: "skill://acme/reports/SKILL.md",
|
||||
frontmatter: { name: "reports", description: "Acme ledger" },
|
||||
resources: [
|
||||
{ uri: "skill://acme/reports/SKILL.md", digest: CLEAN_DIGEST, size: 1 },
|
||||
],
|
||||
};
|
||||
const globex: SkillEntry = {
|
||||
uri: "skill://globex/reports/SKILL.md",
|
||||
frontmatter: { name: "reports", description: "Globex ledger" },
|
||||
resources: [
|
||||
{
|
||||
uri: "skill://globex/reports/SKILL.md",
|
||||
digest: CLEAN_DIGEST,
|
||||
size: 1,
|
||||
},
|
||||
],
|
||||
};
|
||||
const { lastFrame, stdin } = render(
|
||||
<SkillsTab
|
||||
skills={[acme, globex]}
|
||||
pageCount={1}
|
||||
inspectorClient={null}
|
||||
width={160}
|
||||
height={30}
|
||||
focusedPane="list"
|
||||
/>,
|
||||
);
|
||||
const frame = lastFrame() ?? "";
|
||||
// Both rows carry the warning mark, not the error one.
|
||||
expect(frame.match(/! reports/g)).toHaveLength(2);
|
||||
expect(frame).not.toContain("✗ reports");
|
||||
expect(frame).toContain("also declares the name");
|
||||
// The detail pane names the OTHER skill, which is the disambiguation.
|
||||
expect(frame).toContain("skill://globex/reports/SKILL.md");
|
||||
|
||||
stdin.write(DOWN);
|
||||
await tick();
|
||||
expect(lastFrame() ?? "").toContain("skill://acme/reports/SKILL.md");
|
||||
});
|
||||
|
||||
it("shows the details footer only when the details pane is focused", () => {
|
||||
const unfocused = render(
|
||||
<SkillsTab
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ import type { InspectorClient } from "@inspector/core/mcp/index.js";
|
|||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import {
|
||||
checkSkillConformance,
|
||||
checkSkillNameCollisions,
|
||||
skillDisplayName,
|
||||
skillUriIdentity,
|
||||
type SkillIssue,
|
||||
} from "@inspector/core/mcp/skills.js";
|
||||
import {
|
||||
|
|
@ -215,7 +217,15 @@ export function SkillsTab({
|
|||
|
||||
const listWidth = Math.floor(width * 0.4);
|
||||
const detailWidth = width - listWidth;
|
||||
const issues = selectedSkill ? checkSkillConformance(selectedSkill) : [];
|
||||
// A name collision is a property of the LISTING, not of an entry, so it is
|
||||
// computed once here and merged into each entry's own findings — which is
|
||||
// what carries it into the row marks below as well as the detail pane.
|
||||
const collisions = checkSkillNameCollisions(skills);
|
||||
const findingsFor = (skill: SkillEntry): SkillIssue[] => {
|
||||
const collision = collisions.get(skillUriIdentity(skill.uri));
|
||||
return [...checkSkillConformance(skill), ...(collision ? [collision] : [])];
|
||||
};
|
||||
const issues = selectedSkill ? findingsFor(selectedSkill) : [];
|
||||
const activeReport =
|
||||
selectedSkill && report?.key === entryKey(selectedSkill)
|
||||
? report.result
|
||||
|
|
@ -270,7 +280,7 @@ export function SkillsTab({
|
|||
// The per-row mark is the static conformance verdict, which
|
||||
// costs nothing — it is what makes a bad skill visible in the
|
||||
// list rather than only after selecting it.
|
||||
const rowIssues = checkSkillConformance(skill);
|
||||
const rowIssues = findingsFor(skill);
|
||||
const worst = rowIssues.some((it) => it.severity === "error")
|
||||
? "error"
|
||||
: rowIssues.length > 0
|
||||
|
|
|
|||
Loading…
Reference in a new issue