fix: address Copilot review round 4 on #2248

Eight findings. Two were visible in a terminal capture I had already taken
and read past.

**A digest mismatch rendered with no digests.** `verifySkillResource` sets
`reason` for a SIZE mismatch — which short-circuits before hashing — while a
digest mismatch carries `expectedDigest` / `actualDigest` and no `reason`, so
the TUI printed a bare `✗ notes.md` with nothing to act on. `failureDetail`
now falls back to the expected/actual pair, truncated to fit the pane.

**A dynamic skill's read failure rendered nowhere.** Its synthetic
`read-error` row lives in the report, not the manifest, and the manifest is
empty for such a skill by definition — so the pane said only "Verification
FAILED". There is a Read failures block for any report file the manifest does
not cover, matched on normalized identity.

**The non-finite sentinel could be aliased.** A listing whose value genuinely
was `{"#non-finite":"NaN"}` canonicalized identically to a served `.nan`, so
the round-3 fix moved the bug rather than closing it. Any encoding into the
value space can be aliased by a document containing the encoding, so the
comparison is structural now (`jsonLikeEqual`) and there is no sentinel at
all; `Object.is` gives NaN === NaN while keeping ±Infinity distinct.
Serialization is used only for the message.

**`parseSkillFrontmatter("null")` returned `{ fields: {} }`,** contradicting
its own non-mapping contract: an explicit null scalar parses to exactly what
an empty block does. They are told apart by the source, since the parsed
value cannot.

Also: the `files` doc comment still claimed it is empty for a dynamic skill,
which the round-2 fix made untrue; and three inline Mantine elements with two
static styling props each are extracted to `.withProps()` constants, per the
convention the rest of the file follows.

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:
cliffhall 2026-09-07 19:55:17 -04:00
commit 799763543f
3 changed files with 313 additions and 33 deletions

View file

@ -93,6 +93,21 @@ export type ParsedFrontmatter =
* everything. An *empty* block (`fields: {}`) is a different fact and is
* reported as a successful parse of nothing.
*/
/**
* Whether a frontmatter block holds anything but whitespace and comments.
*
* Comments are stripped only from the start of a line: a `#` inside a value is
* part of that value, and treating it as a comment would call a block with real
* content empty. That is the safe direction — mistaking content for emptiness
* here would turn a malformed document back into "no fields", which is the bug
* this exists to prevent.
*/
function hasContent(yamlText: string): boolean {
return yamlText
.split("\n")
.some((line) => line.trim() !== "" && !line.trimStart().startsWith("#"));
}
export function parseSkillFrontmatter(yamlText: string): ParsedFrontmatter {
let parsed: unknown;
try {
@ -100,9 +115,16 @@ export function parseSkillFrontmatter(yamlText: string): ParsedFrontmatter {
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
// `null` is what an empty (or comment-only) block parses to — a real, if
// degenerate, mapping of no fields rather than a malformed one.
if (parsed === null || parsed === undefined) return { fields: {} };
// An empty (or comment-only) block parses to `null`, and so does an explicit
// `null` / `~` scalar — but only the first is a degenerate mapping of no
// fields. The second is a non-mapping document, and returning `{ fields: {} }`
// for it contradicts this function's own contract (Copilot). They are told
// apart by the SOURCE, since the parsed value cannot distinguish them.
if (parsed === null || parsed === undefined) {
return hasContent(yamlText)
? { error: "Frontmatter is not a YAML mapping of fields." }
: { fields: {} };
}
if (typeof parsed !== "object" || Array.isArray(parsed)) {
return {
error: "Frontmatter is not a YAML mapping of fields.",

View file

@ -561,16 +561,6 @@ function canonicalEntry(entry: SkillEntry): string {
/** Object keys sorted recursively; array ORDER is preserved throughout. */
function canonicalize(value: unknown): unknown {
// YAML can express `.nan` and `.inf`; JSON cannot. `JSON.stringify` turns
// every one of them into `null`, so without this a served `x: .nan` would
// compare EQUAL to a listing declaring `x: null` — a mismatch silently
// reported as agreement (Copilot). The listing side arrived over JSON-RPC and
// can never hold a non-finite number, so one appearing here is always a real
// difference. Rendered as an object, which cannot equal any JSON scalar, and
// which names the value in the finding rather than hiding it.
if (typeof value === "number" && !Number.isFinite(value)) {
return { "#non-finite": String(value) };
}
if (Array.isArray(value)) return value.map(canonicalize);
if (value === null || typeof value !== "object") return value;
return sortKeys(value as Record<string, unknown>);
@ -760,6 +750,65 @@ export async function verifySkillResource(
};
}
/**
* Structural equality for JSON-like values, with YAML's extra scalars handled.
*
* ⚠️ **Comparison is structural rather than serialized, and that is the point.**
* `JSON.stringify` is not injective over what a YAML parser produces: `.nan`,
* `.inf` and `-.inf` all serialize to `null`, so a served `x: .nan` compared
* EQUAL to a listing declaring `x: null` — and to each other. An earlier fix
* encoded non-finite numbers as a sentinel object, which merely moved the
* problem: a listing whose value genuinely *was* that object aliased the
* sentinel and matched a served `.nan` (Copilot). Any encoding into the value
* space can be aliased by a document containing the encoding, so there is no
* sentinel here at all.
*
* `Object.is` on the number path is what makes it work: it holds `NaN` equal to
* `NaN`, keeps `Infinity` and `-Infinity` distinct, and never equates either
* with `null`.
*/
function jsonLikeEqual(a: unknown, b: unknown): boolean {
if (typeof a === "number" || typeof b === "number") return Object.is(a, b);
if (a === null || b === null) return a === b;
if (typeof a !== "object" || typeof b !== "object") return Object.is(a, b);
const aArray = Array.isArray(a);
if (aArray !== Array.isArray(b)) return false;
if (aArray) {
const x = a as unknown[];
const y = b as unknown[];
// Array ORDER is significant — a YAML sequence is ordered.
return x.length === y.length && x.every((v, i) => jsonLikeEqual(v, y[i]));
}
const x = a as Record<string, unknown>;
const y = b as Record<string, unknown>;
const keys = Object.keys(x);
// Key order is not meaningful in either JSON or YAML, so only the key SET and
// the values matter.
return (
keys.length === Object.keys(y).length &&
keys.every((k) => Object.hasOwn(y, k) && jsonLikeEqual(x[k], y[k]))
);
}
/**
* A frontmatter value as it should READ in a finding.
*
* `JSON.stringify` renders every non-finite number as `null`, which would print
* "the listing says null but the served file says null" for a real difference.
* Only the display is special-cased; the comparison above never goes through a
* string, so this cannot reintroduce an aliasing bug.
*/
function displayValue(value: unknown): string {
if (typeof value === "number" && !Number.isFinite(value)) {
return String(value);
}
return JSON.stringify(canonicalize(value), (_key, member: unknown) =>
typeof member === "number" && !Number.isFinite(member)
? `<${String(member)}>`
: member,
);
}
/**
* Compare the fetched `SKILL.md`'s own frontmatter against the frontmatter the
* entry advertised, field by field — the SEP-2640 obligation a digest cannot
@ -848,13 +897,11 @@ export function checkSkillFrontmatterMatch(
});
continue;
}
const listedJson = JSON.stringify(canonicalize(listed));
const servedJson = JSON.stringify(canonicalize(served));
if (listedJson !== servedJson) {
if (!jsonLikeEqual(listed, served)) {
issues.push({
code: "frontmatter-mismatch",
severity: "error",
message: `Field "${field}" differs: the listing says ${listedJson} but the served SKILL.md says ${servedJson}.`,
message: `Field "${field}" differs: the listing says ${displayValue(listed)} but the served SKILL.md says ${displayValue(served)}.`,
resourceUri: entry.uri,
});
}

View file

@ -67,7 +67,16 @@ export interface SkillVerifyReport {
* frontmatter discrepancy.
*/
frontmatter: SkillIssue[];
/** One entry per manifest file, in manifest order. Empty for `"dynamic"`. */
/**
* One entry per manifest file, in manifest order.
*
* ⚠️ **Not necessarily empty for a `"dynamic"` skill.** Such a skill has no
* manifest rows, but a failed read of its own `SKILL.md` — the file the
* mandatory frontmatter comparison needs — is recorded here as a synthetic
* `read-error` row against the entry's URI, so the failure is visible and
* fails the report rather than passing silently. A consumer must not assume
* `files` mirrors the manifest one-for-one (Copilot).
*/
files: SkillFileReport[];
/**
* False when anything the SEP makes a MUST was broken: an error-severity