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:
parent
6b83727332
commit
799763543f
3 changed files with 313 additions and 33 deletions
|
|
@ -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"}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in a new issue