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

@ -411,31 +411,42 @@ export function totalSkillBytes(resources: readonly SkillResource[]): number {
* presents it as "the snapshot moved" and leaves the judgement to the reader.
*/
export function skillEntriesMatch(a: SkillEntry, b: SkillEntry): boolean {
return canonicalJson(a) === canonicalJson(b);
return canonicalEntry(a) === canonicalEntry(b);
}
/** Deterministic JSON: object keys sorted recursively, and a skill's manifest
* sorted by URI so its enumeration order carries no meaning. */
function canonicalJson(value: unknown): string {
return JSON.stringify(canonicalize(value));
/**
* Deterministic JSON for one entry: object keys sorted recursively, and **the
* entry's own manifest** — nothing else — sorted by URI.
*
* The manifest sort is deliberately not recursive. `frontmatter` is verbatim
* arbitrary JSON from the skill author, so a custom `frontmatter.metadata.
* resources` array would be caught by a recursive rule and two genuinely
* different frontmatters would compare equal. Only `SkillEntry.resources` is
* a set; every other array keeps its order.
*/
function canonicalEntry(entry: SkillEntry): string {
const { resources, ...rest } = entry;
const manifest = Array.isArray(resources)
? [...resources]
.sort((x, y) => String(x?.uri).localeCompare(String(y?.uri)))
.map(canonicalize)
: resources;
return JSON.stringify({ ...sortKeys(rest), resources: manifest });
}
/** Object keys sorted recursively; array ORDER is preserved throughout. */
function canonicalize(value: unknown): unknown {
if (Array.isArray(value)) return value.map(canonicalize);
if (value === null || typeof value !== "object") return value;
const entries = Object.entries(value as Record<string, unknown>)
.map(([key, member]): [string, unknown] => {
// The manifest is a set; only its contents are meaningful.
if (key === "resources" && Array.isArray(member)) {
const sorted = [...(member as SkillResource[])].sort((x, y) =>
String(x?.uri).localeCompare(String(y?.uri)),
);
return [key, sorted.map(canonicalize)];
}
return [key, canonicalize(member)];
})
.sort(([x], [y]) => x.localeCompare(y));
return Object.fromEntries(entries);
return sortKeys(value as Record<string, unknown>);
}
function sortKeys(value: Record<string, unknown>): Record<string, unknown> {
return Object.fromEntries(
Object.entries(value)
.map(([key, member]): [string, unknown] => [key, canonicalize(member)])
.sort(([x], [y]) => x.localeCompare(y)),
);
}
/** Outcome of comparing a fetched file against its advertised digest. */