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

View file

@ -36,6 +36,7 @@ import {
} from "@inspector/core/mcp/skillsSchemas.js";
import {
verifySkills,
type SkillFileReport,
type SkillVerifyReport,
} from "@inspector/core/mcp/skillsVerification.js";
import { useSelectableList } from "../hooks/useSelectableList.js";
@ -98,6 +99,23 @@ function entryKey(entry: SkillEntry): string {
return JSON.stringify(entry);
}
/**
* The explanation printed under a failed file row.
*
* `verifySkillResource` sets `reason` for a SIZE mismatch but not for a digest
* one — that carries `expectedDigest` / `actualDigest` instead — so a pane that
* rendered only `reason` showed a bare `✗ notes.md` and never said why, leaving
* the failure unactionable (Copilot). Digests are truncated because the pane is
* 40-odd columns wide and the first bytes are enough to see that two differ;
* the CLI report carries them in full.
*/
function failureDetail(file: SkillFileReport): string | undefined {
if (file.reason) return file.reason;
if (file.status !== "mismatch") return undefined;
const short = (d: string | undefined) => (d ? `${d.slice(0, 23)}…` : "—");
return `expected ${short(file.expectedDigest)}, got ${short(file.actualDigest)}`;
}
/** The file name a manifest URI ends in, for a list that must fit 40 columns. */
function fileNameOf(uri: string): string {
const cut = uri.lastIndexOf("/");
@ -254,6 +272,14 @@ export function SkillsTab({
selectedSkill && selectedSkill.resources !== DYNAMIC_RESOURCES
? selectedSkill.resources
: [];
// Compared on normalized identity, like every other URI comparison here, so a
// manifest entry written in an equivalent form is not reported twice.
const manifestIdentities = new Set(
manifest.map((resource) => skillUriIdentity(resource.uri)),
);
const extraReportFiles = (activeReport?.files ?? []).filter(
(file) => !manifestIdentities.has(skillUriIdentity(file.uri)),
);
return (
<Box flexDirection="row" width={width} height={height}>
@ -410,15 +436,48 @@ export function SkillsTab({
<Text dimColor> ({resource.size} B)</Text>
) : null}
</Text>
{fileReport?.reason && (
{fileReport && failureDetail(fileReport) && (
<Box paddingLeft={4} flexShrink={0}>
<Text color="red">{fileReport.reason}</Text>
<Text color="red">{failureDetail(fileReport)}</Text>
</Box>
)}
</Box>
);
})}
{/* A report can carry a file the MANIFEST does not — a dynamic
skill has no rows at all, yet a failed read of its own
SKILL.md is recorded so the failure is visible. Rendering only
manifest rows left "Verification FAILED" with no diagnosis
anywhere on screen (Copilot). */}
{extraReportFiles.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>
<Text bold>Read failures:</Text>
</Box>
{extraReportFiles.map((file, idx) => (
<Box
key={`extra-${idx}`}
paddingLeft={2}
flexShrink={0}
flexDirection="column"
>
<Text>
<Text color={FILE_COLOR[file.status] ?? "white"}>
{FILE_MARK[file.status] ?? "?"}{" "}
</Text>
{fileNameOf(file.uri)}
</Text>
{failureDetail(file) && (
<Box paddingLeft={4} flexShrink={0}>
<Text color="red">{failureDetail(file)}</Text>
</Box>
)}
</Box>
))}
</>
)}
{activeReport && activeReport.frontmatter.length > 0 && (
<>
<Box marginTop={1} flexShrink={0}>