fix: address Copilot review round 7 on #2251

- skills.ts: stop sorting `resources` arrays recursively. Only the entry's
  OWN manifest is a set; `frontmatter` is verbatim arbitrary JSON, so a
  custom `frontmatter.metadata.resources` array was being sorted too and
  two genuinely different frontmatters compared equal. The manifest sort is
  now done once, at the top level, and every other array keeps its order.
- SkillsScreen: invalidate on the whole selected entry, not just its
  manifest. The `skills/get` comparison covers `frontmatter`, so a refresh
  that changed only a description left a stale "matches skills/list"
  verdict on screen — computed against an entry that no longer existed.
  Re-verifying after a metadata-only refresh is the cheap direction to be
  wrong in; showing a match against a different entry is not.
- SkillsScreen: give each "Verify all" invocation a token, so a finalizer
  can only ever clear its own batch rather than any batch with a matching
  manifest key.
- SkillsScreen.stories: supply `onGetSkill`. Every story rendered the
  "Fetch with skills/get" button, and clicking it threw.

On the batch token: the A → B → A sequence the review describes is not
actually reachable, because the button is disabled whenever the in-flight
batch's key matches the selection — returning to A finds it disabled, not
clickable. The token is defence in depth for the same property, and the
test asserts what is really guaranteed rather than the unreachable path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018kfrH3uRNUM4JAk1reKtyY
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-04 23:36:35 -04:00
commit 1f33ba93be

View file

@ -10,6 +10,7 @@ import {
getSkillsExtension,
isSkillsExtensionSupported,
normalizeSkillUri,
skillEntriesMatch,
sha256Digest,
skillDisplayName,
skillNameFromUri,
@ -172,6 +173,68 @@ describe("normalizeSkillUri", () => {
});
});
describe("skillEntriesMatch", () => {
const base = (): SkillEntry => ({
uri: "skill://demo/SKILL.md",
frontmatter: { name: "demo", description: "d" },
resources: [
{ uri: "skill://demo/SKILL.md", digest: DIGEST, size: 20 },
{ uri: "skill://demo/ref.md", digest: DIGEST, size: 10 },
],
});
it("ignores object key order", () => {
const reordered = {
resources: base().resources,
uri: base().uri,
frontmatter: { description: "d", name: "demo" },
};
expect(skillEntriesMatch(base(), reordered)).toBe(true);
});
it("ignores manifest order, because the manifest is a set", () => {
const reversed = {
...base(),
resources: [...(base().resources as object[])].reverse(),
} as SkillEntry;
expect(skillEntriesMatch(base(), reversed)).toBe(true);
});
it("does NOT reorder an array nested in frontmatter", () => {
// `frontmatter` is verbatim arbitrary JSON from the skill author, so a
// custom `resources` array inside it is an ordinary list. A recursive
// sort would make these two genuinely different entries compare equal.
const withNested = (order: string[]): SkillEntry => ({
...base(),
frontmatter: {
...base().frontmatter,
metadata: { resources: order.map((uri) => ({ uri })) },
},
});
expect(
skillEntriesMatch(withNested(["a", "b"]), withNested(["b", "a"])),
).toBe(false);
expect(
skillEntriesMatch(withNested(["a", "b"]), withNested(["a", "b"])),
).toBe(true);
});
it("still sees a real difference", () => {
expect(
skillEntriesMatch(base(), {
...base(),
frontmatter: { name: "demo", description: "changed" },
}),
).toBe(false);
});
it("compares the dynamic form without sorting it", () => {
const dynamic = { ...base(), resources: "dynamic" } as SkillEntry;
expect(skillEntriesMatch(dynamic, { ...dynamic })).toBe(true);
expect(skillEntriesMatch(dynamic, base())).toBe(false);
});
});
describe("skillDisplayName", () => {
it("prefers the declared frontmatter name", () => {
expect(skillDisplayName(entry())).toBe("demo");