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

@ -380,6 +380,26 @@ const ResourceNameCaption = Text.withProps({
maw: "50%",
});
/** A read that failed, in the Directory section. */
const ReadFailureAlert = Alert.withProps({
color: "red",
variant: "light",
title: "Read failed",
});
/**
* The directory-vs-manifest divergence banner. Yellow: the server is not
* necessarily wrong — its listing may simply be newer than the held entry.
*/
const UnlistedChildrenAlert = Alert.withProps({
color: "yellow",
variant: "light",
title: "This directory lists files the entry does not",
});
/** The em dash standing in for a verdict that does not apply to a row. */
const NoVerdictText = Text.withProps({ size: "xs", c: "dimmed" });
const IssueStack = Stack.withProps({
gap: "xs",
});
@ -1984,9 +2004,7 @@ export function SkillsScreen({
</Group>
</SectionControlsRow>
{directoryError !== undefined && (
<Alert color="red" variant="light" title="Read failed">
{directoryError}
</Alert>
<ReadFailureAlert>{directoryError}</ReadFailureAlert>
)}
{/* Stated in prose the first time the two views
disagree, because the per-row chip alone does not say
@ -1994,12 +2012,7 @@ export function SkillsScreen({
re-approval" is what SEP-2640 asks a host to present
here, rather than a read error. */}
{unlistedChildren.length > 0 && (
<Alert
color="yellow"
variant="light"
title="This directory lists files the entry does not"
data-testid="skill-directory-unlisted"
>
<UnlistedChildrenAlert data-testid="skill-directory-unlisted">
The server is serving {unlistedChildren.length} file
{unlistedChildren.length === 1 ? "" : "s"} here that
the held <Code>skills/list</Code> entry does not
@ -2009,7 +2022,7 @@ export function SkillsScreen({
verification failure equivalent to a digest mismatch.
Re-fetch the entry with <Code>skills/get</Code> to see
whether the skill has changed.
</Alert>
</UnlistedChildrenAlert>
)}
{directoryChildren !== undefined &&
(directoryChildren.length === 0 ? (
@ -2074,9 +2087,7 @@ export function SkillsScreen({
<Table.Td>{child.mimeType ?? "—"}</Table.Td>
<Table.Td>
{isDir || isDynamic ? (
<Text size="xs" c="dimmed">
—
</Text>
<NoVerdictText>—</NoVerdictText>
) : (
<CountBadge
color={listed ? "green" : "yellow"}

View file

@ -96,6 +96,33 @@ describe("parseSkillFrontmatter (#2248)", () => {
expect(parseSkillFrontmatter("# just a comment")).toEqual({ fields: {} });
});
it("reports an explicit null scalar as an error, not as no fields", () => {
// `null` and `~` parse to the same value an EMPTY block does, but only the
// empty one is a degenerate mapping — returning `{ fields: {} }` for an
// explicit null scalar contradicts this function's own contract (Copilot).
for (const src of ["null", "~", " null ", "# lead\nnull"]) {
expect(parseSkillFrontmatter(src)).toEqual({
error: expect.stringContaining("mapping"),
});
}
});
it("still reads a comment-only block as a mapping of no fields", () => {
// The distinction is made on the SOURCE, so this must not regress.
expect(parseSkillFrontmatter("# just a comment\n\n # another")).toEqual({
fields: {},
});
});
it("does not mistake a leading # inside a value for a comment", () => {
// Comments are stripped only at the start of a line; a `#` inside a value
// is part of it, and treating it as a comment would call a block with real
// content empty.
expect(parseSkillFrontmatter('name: "#hashtag"')).toEqual({
fields: { name: "#hashtag" },
});
});
it("reports a scalar block as an error rather than as no fields", () => {
// `just a string` parses successfully as a scalar. Reporting it as an
// empty mapping would present a malformed file as one that merely omitted

View file

@ -1005,6 +1005,29 @@ describe("checkSkillFrontmatterMatch (#2248)", () => {
expect(both[1].message).toContain("-Infinity");
});
it("cannot be fooled by a listing that looks like an encoding", () => {
// The regression this guards: encoding non-finite numbers as a sentinel
// object let a listing whose value genuinely WAS that object alias it and
// match a served `.nan` (Copilot). The comparison is structural now, so
// there is no encoding to alias.
const issues = checkSkillFrontmatterMatch(
entry({ x: { "#non-finite": "NaN" } }),
file("x: .nan"),
);
expect(issues).toHaveLength(1);
expect(issues[0].code).toBe("frontmatter-mismatch");
});
it("still matches a listing object that equals the served mapping", () => {
// …and the guard must not make a genuine agreement look like a difference.
expect(
checkSkillFrontmatterMatch(
entry({ x: { "#non-finite": "NaN" } }),
file('x:\n "#non-finite": NaN'),
),
).toEqual([]);
});
it("still matches a null the served file also writes as null", () => {
// The fix must not turn a genuine agreement into a finding.
expect(