fix: address Copilot review round 10 on #2248
Round 10 raised no new inline comments; three suppressed findings, two fixed and one declined with a reason. **`Object.is` held `0` and `-0` distinct**, so a listing carrying JSON `0` against a served YAML `-0` produced a mismatch — reported as "the listing says 0 but the served SKILL.md says 0", a false finding with an unintelligible explanation. JSON does not distinguish them, so neither may this: `===` for finite numbers, `Object.is` kept only for the non-finite cases it was introduced for. **Browsing a supporting file erased an observed failure.** The frontmatter check ran off whatever the viewer was showing, so opening a second file made `showingSkillMd` false and dropped the finding and its error count, with the skill unchanged. The entry's text is skill-scoped now, written by whichever read produces it first — the on-selection preview or a verification — and invalidated only with `manifestKey`. That also subsumes the earlier same-fetch requirement rather than sitting beside it. **Declined: the second stderr line on exit 7.** `--verify` writes its summary and then the ordinary `ErrorEnvelope` that EVERY non-zero exit writes, which is the documented contract and exactly what `--strict` does. Suppressing it would make this one command's failure output unparseable by a caller branching on `.code`. What was wrong is the README, which implied stderr carries only the summary; it now states both lines and why. 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
88c1b89575
commit
50441f4d85
5 changed files with 128 additions and 13 deletions
|
|
@ -364,8 +364,13 @@ Stdout is **NDJSON, one report per skill**, in listing order:
|
||||||
```
|
```
|
||||||
|
|
||||||
Stderr gets a one-line summary, so a reader who piped stdout into `jq` still
|
Stderr gets a one-line summary, so a reader who piped stdout into `jq` still
|
||||||
sees the verdict. `--method skills/get --uri <skill>` verifies exactly one
|
sees the verdict — and then, on a failing run, the ordinary
|
||||||
skill, in the same shape.
|
[`ErrorEnvelope`](#exit-codes--error-envelopes) line that **every** non-zero
|
||||||
|
exit writes. Two stderr lines on failure, one on success, which is the same
|
||||||
|
shape `--strict` produces and is why the envelope is not suppressed here: a
|
||||||
|
caller branching on `.code` should not have to special-case this command.
|
||||||
|
`--method skills/get --uri <skill>` verifies exactly one skill, in the same
|
||||||
|
shape.
|
||||||
|
|
||||||
**What fails the run.** `ok` is false — and the exit code is `7` — for anything
|
**What fails the run.** `ok` is false — and the exit code is `7` — for anything
|
||||||
SEP-2640 makes a MUST: an error-severity conformance finding, a digest or size
|
SEP-2640 makes a MUST: an error-severity conformance finding, a digest or size
|
||||||
|
|
|
||||||
|
|
@ -2458,6 +2458,42 @@ describe("SkillsScreen frontmatter cross-check (#2248)", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("keeps a frontmatter finding when the reader opens another file", async () => {
|
||||||
|
// The check ran off whatever the viewer was showing, so opening a
|
||||||
|
// supporting file made `showingSkillMd` false and silently dropped the
|
||||||
|
// finding AND its error count — erasing an observed conformance failure
|
||||||
|
// because the reader browsed a second file, with the skill unchanged
|
||||||
|
// (Copilot).
|
||||||
|
const user = userEvent.setup();
|
||||||
|
const lying: SkillEntry = {
|
||||||
|
...CLEAN_SKILL,
|
||||||
|
frontmatter: { name: "data-analysis", description: "Disagrees" },
|
||||||
|
};
|
||||||
|
renderWithMantine(<ControlledSkillsScreen skills={[lying]} />);
|
||||||
|
await user.click(screen.getByText("data-analysis"));
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(
|
||||||
|
screen.getByTestId("skill-frontmatter-issues"),
|
||||||
|
).toBeInTheDocument(),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Open a supporting file: the finding is about the SKILL, not the view.
|
||||||
|
await user.click(
|
||||||
|
screen.getByRole("button", {
|
||||||
|
name: "skill://data-analysis/reference.md",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(readFixtureFile).toHaveBeenCalledWith(
|
||||||
|
"skill://data-analysis/reference.md",
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("skill-frontmatter-issues")).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole("button", { name: /Conformance/ }),
|
||||||
|
).toHaveTextContent("1 error(s)");
|
||||||
|
});
|
||||||
|
|
||||||
it("counts frontmatter findings in the Conformance badge", async () => {
|
it("counts frontmatter findings in the Conformance badge", async () => {
|
||||||
// The findings render inside this section, so counting only the static
|
// The findings render inside this section, so counting only the static
|
||||||
// listing issues left the badge saying `0 error(s)` above a red
|
// listing issues left the badge saying `0 error(s)` above a red
|
||||||
|
|
|
||||||
|
|
@ -882,7 +882,7 @@ export function SkillsScreen({
|
||||||
});
|
});
|
||||||
|
|
||||||
const fileStates = verification.key === manifestKey ? verification.files : {};
|
const fileStates = verification.key === manifestKey ? verification.files : {};
|
||||||
const verifiedEntryText =
|
const entrySourceText =
|
||||||
verification.key === manifestKey ? verification.entryText : undefined;
|
verification.key === manifestKey ? verification.entryText : undefined;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1037,7 +1037,7 @@ export function SkillsScreen({
|
||||||
* whichever one happened to be current when this callback was created.
|
* whichever one happened to be current when this callback was created.
|
||||||
*/
|
*/
|
||||||
const showResource = useCallback(
|
const showResource = useCallback(
|
||||||
(uri: string, key: string) => {
|
(uri: string, key: string, isEntryUri = false) => {
|
||||||
const attempt = (nextAttempt.current += 1);
|
const attempt = (nextAttempt.current += 1);
|
||||||
// A click handler cannot await, and this chain terminates in its own
|
// A click handler cannot await, and this chain terminates in its own
|
||||||
// `catch` that surfaces the message in the viewer. Both arms go through
|
// `catch` that surfaces the message in the viewer. Both arms go through
|
||||||
|
|
@ -1056,7 +1056,32 @@ export function SkillsScreen({
|
||||||
// to announce the previous one for as long as the read takes.
|
// to announce the previous one for as long as the read takes.
|
||||||
writePreview({ uri });
|
writePreview({ uri });
|
||||||
void onReadSkillFile(uri)
|
void onReadSkillFile(uri)
|
||||||
.then((contents) => writePreview({ uri, contents }))
|
.then((contents) => {
|
||||||
|
writePreview({ uri, contents });
|
||||||
|
// A successful read of the skill's OWN file is recorded in the
|
||||||
|
// skill-scoped slot, so the frontmatter verdict it produces outlives
|
||||||
|
// the reader opening a supporting file — see
|
||||||
|
// `VerificationState.entryText`. Only on success, and only for that
|
||||||
|
// file: a failed or unrelated read must not overwrite an answer.
|
||||||
|
if (isEntryUri) {
|
||||||
|
let text: string | undefined;
|
||||||
|
try {
|
||||||
|
text = bytesToText(skillFileBytes(contents));
|
||||||
|
} catch {
|
||||||
|
return; // neither text nor blob; the viewer reports it
|
||||||
|
}
|
||||||
|
setVerification((prev) =>
|
||||||
|
prev.key !== null && prev.key !== key
|
||||||
|
? prev
|
||||||
|
: {
|
||||||
|
...prev,
|
||||||
|
key,
|
||||||
|
files: prev.key === key ? prev.files : {},
|
||||||
|
entryText: text,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
})
|
||||||
.catch((err: unknown) => {
|
.catch((err: unknown) => {
|
||||||
writePreview({
|
writePreview({
|
||||||
uri,
|
uri,
|
||||||
|
|
@ -1117,7 +1142,7 @@ export function SkillsScreen({
|
||||||
}
|
}
|
||||||
if (autoReadKey.current === manifestKey) return;
|
if (autoReadKey.current === manifestKey) return;
|
||||||
autoReadKey.current = manifestKey;
|
autoReadKey.current = manifestKey;
|
||||||
showResource(selectedUri, manifestKey);
|
showResource(selectedUri, manifestKey, true);
|
||||||
}, [manifestKey, selectedUri, showResource]);
|
}, [manifestKey, selectedUri, showResource]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -1453,10 +1478,11 @@ export function SkillsScreen({
|
||||||
*/
|
*/
|
||||||
const frontmatterIssues = useMemo(() => {
|
const frontmatterIssues = useMemo(() => {
|
||||||
if (!selected) return [];
|
if (!selected) return [];
|
||||||
// Prefer the text the VERIFICATION read, so the digest verdict above and
|
// The skill-scoped text, whichever read produced it — so this verdict
|
||||||
// this one describe one fetch rather than two.
|
// survives the reader opening another file, and agrees with the digest
|
||||||
if (verifiedEntryText !== undefined) {
|
// verdict when one verification produced both.
|
||||||
return checkSkillFrontmatterMatch(selected, verifiedEntryText);
|
if (entrySourceText !== undefined) {
|
||||||
|
return checkSkillFrontmatterMatch(selected, entrySourceText);
|
||||||
}
|
}
|
||||||
if (!showingSkillMd || preview === undefined) return [];
|
if (!showingSkillMd || preview === undefined) return [];
|
||||||
// Run against the **raw fetched bytes**, not against `previewParts`.
|
// Run against the **raw fetched bytes**, not against `previewParts`.
|
||||||
|
|
@ -1479,7 +1505,7 @@ export function SkillsScreen({
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
return checkSkillFrontmatterMatch(selected, text);
|
return checkSkillFrontmatterMatch(selected, text);
|
||||||
}, [selected, showingSkillMd, preview, verifiedEntryText]);
|
}, [selected, showingSkillMd, preview, entrySourceText]);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The findings rendered as list items — everything except the two that are
|
* The findings rendered as list items — everything except the two that are
|
||||||
|
|
@ -2001,7 +2027,11 @@ export function SkillsScreen({
|
||||||
variant={showing ? "light" : "subtle"}
|
variant={showing ? "light" : "subtle"}
|
||||||
aria-current={showing ? "true" : undefined}
|
aria-current={showing ? "true" : undefined}
|
||||||
onClick={() =>
|
onClick={() =>
|
||||||
showResource(resource.uri, manifestKey)
|
showResource(
|
||||||
|
resource.uri,
|
||||||
|
manifestKey,
|
||||||
|
isSelfResource(resource),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{resource.uri}
|
{resource.uri}
|
||||||
|
|
|
||||||
|
|
@ -1028,6 +1028,33 @@ describe("checkSkillFrontmatterMatch (#2248)", () => {
|
||||||
).toEqual([]);
|
).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("does not report -0 against 0 as a difference", () => {
|
||||||
|
// `Object.is` holds them distinct; JSON does not (`JSON.stringify(-0)` is
|
||||||
|
// `"0"`), so this produced a false finding whose message read "the listing
|
||||||
|
// says 0 but the served SKILL.md says 0" (Copilot).
|
||||||
|
expect(checkSkillFrontmatterMatch(entry({ a: 0 }), file("a: -0"))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
expect(checkSkillFrontmatterMatch(entry({ a: -0 }), file("a: 0"))).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still holds NaN equal to NaN after the -0 fix", () => {
|
||||||
|
// `===` alone would hold NaN unequal to itself, which is why the two
|
||||||
|
// comparisons are combined rather than either used on its own.
|
||||||
|
expect(
|
||||||
|
checkSkillFrontmatterMatch(entry({ a: null }), file("a: .nan")),
|
||||||
|
).toHaveLength(1);
|
||||||
|
// Two served non-finite values of the SAME kind still agree with each
|
||||||
|
// other, so the combination did not trade one false finding for another.
|
||||||
|
const parsedBoth = checkSkillFrontmatterMatch(
|
||||||
|
entry({ a: 1 }),
|
||||||
|
file("a: 1"),
|
||||||
|
);
|
||||||
|
expect(parsedBoth).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
it("still matches a null the served file also writes as null", () => {
|
it("still matches a null the served file also writes as null", () => {
|
||||||
// The fix must not turn a genuine agreement into a finding.
|
// The fix must not turn a genuine agreement into a finding.
|
||||||
expect(
|
expect(
|
||||||
|
|
|
||||||
|
|
@ -772,7 +772,24 @@ export async function verifySkillResource(
|
||||||
* with `null`.
|
* with `null`.
|
||||||
*/
|
*/
|
||||||
function jsonLikeEqual(a: unknown, b: unknown): boolean {
|
function jsonLikeEqual(a: unknown, b: unknown): boolean {
|
||||||
if (typeof a === "number" || typeof b === "number") return Object.is(a, b);
|
if (typeof a === "number" || typeof b === "number") {
|
||||||
|
// `===` for finite numbers, `Object.is` only for the non-finite ones.
|
||||||
|
//
|
||||||
|
// `Object.is` alone held `0` and `-0` distinct, so a listing carrying JSON
|
||||||
|
// `0` against a served YAML `-0` produced a mismatch — reported as "the
|
||||||
|
// listing says 0 but the served SKILL.md says 0", a false finding with an
|
||||||
|
// unintelligible explanation (Copilot). JSON does not distinguish them
|
||||||
|
// (`JSON.stringify(-0)` is `"0"`), so neither may this. `===` would in turn
|
||||||
|
// hold `NaN` unequal to itself, which is why the two are combined rather
|
||||||
|
// than either used alone.
|
||||||
|
if (typeof a === "number" && typeof b === "number") {
|
||||||
|
return Number.isFinite(a) && Number.isFinite(b)
|
||||||
|
? a === b
|
||||||
|
: Object.is(a, b);
|
||||||
|
}
|
||||||
|
// One side is not a number at all: different types, never equal.
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (a === null || b === null) return a === b;
|
if (a === null || b === null) return a === b;
|
||||||
if (typeof a !== "object" || typeof b !== "object") return Object.is(a, b);
|
if (typeof a !== "object" || typeof b !== "object") return Object.is(a, b);
|
||||||
const aArray = Array.isArray(a);
|
const aArray = Array.isArray(a);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue