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:
parent
4e676a0a54
commit
1f33ba93be
1 changed files with 197 additions and 41 deletions
|
|
@ -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. */
|
||||
|
|
|
|||
Loading…
Reference in a new issue