inspector/core/mcp/skillFile.ts
cliffhall 799763543f 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>
2026-09-07 19:55:17 -04:00

134 lines
6.2 KiB
TypeScript

/**
* The `SKILL.md` file format: splitting a served file into its YAML
* frontmatter and body, and parsing that frontmatter into comparable JSON.
*
* Separate from `skills.ts` so the dependency runs one way. `skills.ts` owns the
* *checks* and needs both halves of this module; this module knows nothing about
* findings, severities, or `SkillEntry`, which is what lets it stay a pure text
* transform with a single import.
*
* ⚠️ **This is the one place in `core/` that imports a YAML parser**, and it is
* imported deliberately rather than hand-rolled. SEP-2640 requires a host to
* *parse* a `SKILL.md`'s frontmatter and compare it field by field against the
* entry's — see `checkSkillFrontmatterMatch` — and a regex approximation of YAML
* would report a conforming server as broken the first time a description
* carried a colon, a quoted string, or a multi-line block scalar. For a tool
* whose entire output is "does this server conform", a checker that is itself
* wrong is worse than no checker. `yaml` was already a repo-root **dependency**
* (reached from `test-servers/src/load-config.ts`), so this adds no new package
* to any manifest — but it does newly put `yaml` on `core/`'s runtime import
* graph, which is why it joins the three bundler `external` lists in the same
* change (see the dependency-placement rules in AGENTS.md).
*/
import { parse as parseYaml } from "yaml";
export interface SkillFileParts {
/**
* The raw YAML between the fences, fences excluded — `undefined` when the
* file has no frontmatter at all. Kept raw as well as parsed because the two
* answer different questions: the parsed form is what the conformance check
* compares, and the bytes are what a reader needs to see when the two
* disagree.
*/
frontmatter?: string;
/** Everything after the closing fence, or the whole file when there is none. */
body: string;
}
/**
* Separate a leading YAML frontmatter fence from the rest of a skill file.
*
* The Skills screen renders the two halves in different places — the
* frontmatter in its own collapsible section, the body in the file viewer — and
* deriving both from **one** split is what stops them disagreeing: the section
* can never show one file's frontmatter while the viewer shows another's, and a
* file with no frontmatter cannot leave a stale section on screen.
*
* It also matters for rendering: the markdown renderer has no frontmatter
* support, so an un-split `---\nname: …\n---` is read as a setext heading and
* painted as a title above the document's real one.
*
* Two deliberate conservatisms, because this must never eat content:
*
* - Only a fence at the very **start** of the file counts. A `---` anywhere
* else is a horizontal rule and is left in the body.
* - A file that opens with `---` but never closes the fence is **not**
* frontmatter; the whole file is returned as the body rather than being
* truncated to nothing.
*/
export function splitSkillFile(text: string): SkillFileParts {
if (!/^---[ \t]*\r?\n/.test(text)) return { body: text };
const rest = text.slice(text.indexOf("\n") + 1);
const close = rest.search(/^---[ \t]*\r?$/m);
if (close === -1) return { body: text };
const frontmatter = rest.slice(0, close).replace(/\r?\n$/, "");
const after = rest.slice(close);
const newline = after.indexOf("\n");
if (newline === -1) return { frontmatter, body: "" };
// Drop the blank line conventionally left between the fence and the body, so
// the document does not open with dead space.
return { frontmatter, body: after.slice(newline + 1).replace(/^\r?\n/, "") };
}
/** Outcome of parsing a frontmatter block. Exactly one member is set. */
export type ParsedFrontmatter =
| { fields: Record<string, unknown> }
| { error: string };
/**
* Parse a frontmatter block into a field map.
*
* Two non-obvious decisions:
*
* - **YAML 1.2 core schema**, which is `yaml`'s default and is what the Agent
* Skills format assumes. It resolves only JSON's own types, so a timestamp
* stays the string the server wrote rather than becoming a `Date` — which
* matters because the other side of the comparison arrived over JSON-RPC and
* can only ever hold JSON types. Under YAML 1.1 the two would differ for a
* date-shaped value that is in fact identical on the wire.
* - **A non-mapping is an error, not an empty map.** `---\njust a string\n---`
* parses successfully as the scalar `"just a string"`, and reporting that as
* "no fields" would present a malformed file as one that merely omitted
* 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 {
parsed = parseYaml(yamlText);
} catch (err) {
return { error: err instanceof Error ? err.message : String(err) };
}
// 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.",
};
}
return { fields: parsed as Record<string, unknown> };
}