Copilot review round 5. - The sidebar tooltip applied `hasError` to the TOTAL, so a tool with one error and three warnings announced as "4 schema portability errors" — a defect the previous round introduced while adding that wording, and worse than the vague text it replaced. Both the tooltip and the accessible label now carry the breakdown, formatted by a new shared `summarizeToolFindings` so the fourth place to phrase a severity count cannot drift from the other three. - Suppress `remote-ref` when the document declares an `$id` anywhere. `$id` establishes a base URI, so with a root `$id` of `https://example.com/root` the ref `https://example.com/root#/$defs/x` resolves to THIS document and needs no fetch — the finding and its "inline the referenced schema" remediation would both be wrong. Classifying correctly needs full RFC 3986 resolution against nested embedded resources, which this module does not do, so it declines rather than guessing. A missed finding is the acceptable direction. - Run the `--strict` validations before every short-circuit return. `servers/list`, `servers/show`, `--list-stored-auth` and `--print-handoff` all return from `parseArgs` before any connect, so the checks further down let `--strict --method servers/list` succeed while silently ignoring a tools/list-only flag. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01UL9retfvAXRgvi6EWk4SY4 Signed-off-by: cliffhall <cliff@futurescale.com>
290 lines
9.2 KiB
TypeScript
290 lines
9.2 KiB
TypeScript
import { describe, it, expect, beforeEach, afterEach } from "vitest";
|
|
import { emitResult, runCli } from "../src/cli.js";
|
|
import { CliExitCodeError, EXIT_CODES } from "../src/error-handler.js";
|
|
import {
|
|
lintListResult,
|
|
toolsFromResult,
|
|
writeSchemaLintReport,
|
|
} from "../src/handlers/schema-lint-report.js";
|
|
|
|
/** A `tools/list` result with one bare-`true` property — the issue's case. */
|
|
const DIRTY_LIST = {
|
|
tools: [
|
|
{
|
|
name: "info",
|
|
inputSchema: { type: "object", properties: {} },
|
|
outputSchema: { type: "object", properties: { data: true } },
|
|
},
|
|
],
|
|
};
|
|
|
|
/** Same shape, but every schema portable. */
|
|
const CLEAN_LIST = {
|
|
tools: [{ name: "ok", inputSchema: { type: "object", properties: {} } }],
|
|
};
|
|
|
|
describe("toolsFromResult", () => {
|
|
it("returns the tools array", () => {
|
|
expect(toolsFromResult(DIRTY_LIST).map((t) => t.name)).toEqual(["info"]);
|
|
});
|
|
|
|
it.each([
|
|
["a missing tools key", {}],
|
|
["a non-array tools value", { tools: "nope" }],
|
|
])("returns nothing for %s", (_label, result) => {
|
|
expect(toolsFromResult(result)).toEqual([]);
|
|
});
|
|
|
|
it("skips entries that are not tool-shaped", () => {
|
|
const result = {
|
|
tools: [null, 3, {}, { name: 7 }, { name: "real", inputSchema: {} }],
|
|
};
|
|
expect(toolsFromResult(result).map((t) => t.name)).toEqual(["real"]);
|
|
});
|
|
});
|
|
|
|
describe("lintListResult", () => {
|
|
it("reports the offending tool", () => {
|
|
const results = lintListResult(DIRTY_LIST);
|
|
expect(results.map((r) => r.toolName)).toEqual(["info"]);
|
|
});
|
|
|
|
it("reports nothing for a clean list", () => {
|
|
expect(lintListResult(CLEAN_LIST)).toEqual([]);
|
|
});
|
|
});
|
|
|
|
describe("writeSchemaLintReport", () => {
|
|
let stderr: string;
|
|
let originalWrite: typeof process.stderr.write;
|
|
|
|
beforeEach(() => {
|
|
stderr = "";
|
|
originalWrite = process.stderr.write;
|
|
process.stderr.write = ((chunk: unknown): boolean => {
|
|
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.stderr.write = originalWrite;
|
|
});
|
|
|
|
it("writes nothing at all when there are no findings", () => {
|
|
writeSchemaLintReport([], true);
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
it("writes a one-line hint without --strict", () => {
|
|
writeSchemaLintReport(lintListResult(DIRTY_LIST), false);
|
|
expect(stderr.trimEnd().split("\n")).toHaveLength(1);
|
|
expect(stderr).toContain("Re-run with --strict");
|
|
});
|
|
|
|
it("writes the full report under --strict", () => {
|
|
writeSchemaLintReport(lintListResult(DIRTY_LIST), true);
|
|
expect(stderr).toContain('Error: tool "info"');
|
|
expect(stderr).toContain("Path: outputSchema.properties.data");
|
|
expect(stderr).toContain("Suggestion: ");
|
|
expect(stderr).not.toContain("Re-run with --strict");
|
|
});
|
|
});
|
|
|
|
describe("emitResult — schema lint wiring (#1005)", () => {
|
|
let stdout: string;
|
|
let stderr: string;
|
|
let originalOut: typeof process.stdout.write;
|
|
let originalErr: typeof process.stderr.write;
|
|
|
|
beforeEach(() => {
|
|
stdout = "";
|
|
stderr = "";
|
|
originalOut = process.stdout.write;
|
|
originalErr = process.stderr.write;
|
|
process.stdout.write = ((chunk: unknown, ...rest: unknown[]): boolean => {
|
|
stdout += typeof chunk === "string" ? chunk : String(chunk);
|
|
(
|
|
rest.find((r) => typeof r === "function") as (() => void) | undefined
|
|
)?.();
|
|
return true;
|
|
}) as typeof process.stdout.write;
|
|
process.stderr.write = ((chunk: unknown): boolean => {
|
|
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.stdout.write = originalOut;
|
|
process.stderr.write = originalErr;
|
|
});
|
|
|
|
it("exits 6 under --strict when a finding is error-severity", async () => {
|
|
const promise = emitResult(DIRTY_LIST, undefined, {
|
|
method: "tools/list",
|
|
strict: true,
|
|
format: "text",
|
|
});
|
|
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
|
|
await promise.catch((e: CliExitCodeError) => {
|
|
expect(e.exitCode).toBe(EXIT_CODES.SCHEMA_UNPORTABLE);
|
|
// Not `schema_invalid`: the lint reports schemas that ARE valid JSON
|
|
// Schema and are merely unportable, so the code an automated caller
|
|
// branches on must not claim otherwise.
|
|
expect(e.envelope?.code).toBe("schema_unportable");
|
|
});
|
|
// The result still goes to stdout; only the report is on stderr.
|
|
expect(JSON.parse(stdout)).toEqual(DIRTY_LIST);
|
|
expect(stderr).toContain("Path: outputSchema.properties.data");
|
|
});
|
|
|
|
it("succeeds under --strict when only warnings were found", async () => {
|
|
const warnOnly = {
|
|
tools: [
|
|
{
|
|
name: "w",
|
|
inputSchema: {
|
|
type: "object",
|
|
properties: { a: { type: ["null", "boolean"] } },
|
|
},
|
|
},
|
|
],
|
|
};
|
|
await expect(
|
|
emitResult(warnOnly, undefined, {
|
|
method: "tools/list",
|
|
strict: true,
|
|
format: "text",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(stderr).toContain('Warning: tool "w"');
|
|
});
|
|
|
|
it("prints only the hint, and does not fail, without --strict", async () => {
|
|
await expect(
|
|
emitResult(DIRTY_LIST, undefined, {
|
|
method: "tools/list",
|
|
format: "text",
|
|
}),
|
|
).resolves.toBeUndefined();
|
|
expect(stderr).toContain("Re-run with --strict");
|
|
expect(stderr).not.toContain("Suggestion:");
|
|
});
|
|
|
|
it("folds findings into the --format json envelope under --strict", async () => {
|
|
await emitResult(DIRTY_LIST, undefined, {
|
|
method: "tools/list",
|
|
strict: true,
|
|
format: "json",
|
|
}).catch(() => {
|
|
// exit 6 is expected here; the envelope is what this test asserts.
|
|
});
|
|
const envelope = JSON.parse(stdout) as {
|
|
result: unknown;
|
|
schemaFindings?: { toolName: string }[];
|
|
};
|
|
expect(envelope.result).toEqual(DIRTY_LIST);
|
|
expect(envelope.schemaFindings?.[0]?.toolName).toBe("info");
|
|
});
|
|
|
|
it("leaves the json envelope alone when nothing was found", async () => {
|
|
await emitResult(CLEAN_LIST, undefined, {
|
|
method: "tools/list",
|
|
strict: true,
|
|
format: "json",
|
|
});
|
|
expect(JSON.parse(stdout)).toEqual({ result: CLEAN_LIST });
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
it("does not lint a method other than tools/list", async () => {
|
|
// The same payload under `tools/call` must produce no report at all —
|
|
// `--strict` is a tools/list flag and the CLI rejects it elsewhere.
|
|
await emitResult(DIRTY_LIST, undefined, {
|
|
method: "tools/call",
|
|
strict: true,
|
|
format: "text",
|
|
});
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
it("does not lint an --app-info run", async () => {
|
|
// `runCli` rejects `--strict --app-info` outright (see below), so this is
|
|
// the defensive half: any other Node runner reusing `emitResult` gets the
|
|
// app-info early return rather than a report interleaved with its NDJSON.
|
|
await emitResult(
|
|
DIRTY_LIST,
|
|
{ hasApp: true, toolName: "info" },
|
|
{ method: "tools/list", strict: true, appInfo: true, format: "text" },
|
|
);
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
it("reports isError before the schema verdict", async () => {
|
|
// Both would throw; the tool-level failure is the more specific one and
|
|
// must win, so a caller branching on exit 5 is not shadowed by exit 6.
|
|
const promise = emitResult({ ...DIRTY_LIST, isError: true }, undefined, {
|
|
method: "tools/list",
|
|
strict: true,
|
|
toolName: "info",
|
|
format: "text",
|
|
});
|
|
await promise.catch((e: CliExitCodeError) => {
|
|
expect(e.exitCode).toBe(EXIT_CODES.TOOL_ERROR);
|
|
});
|
|
await expect(promise).rejects.toBeInstanceOf(CliExitCodeError);
|
|
});
|
|
});
|
|
|
|
describe("--strict argument validation", () => {
|
|
it("is rejected with a method other than tools/list", async () => {
|
|
await expect(
|
|
runCli([
|
|
"node",
|
|
"cli",
|
|
"--cli",
|
|
"--method",
|
|
"tools/call",
|
|
"--tool-name",
|
|
"x",
|
|
"--strict",
|
|
"--server-url",
|
|
"http://127.0.0.1:1/mcp",
|
|
]),
|
|
).rejects.toThrow("--strict requires --method tools/list.");
|
|
});
|
|
|
|
it.each([
|
|
["servers/list", ["--method", "servers/list"]],
|
|
["--list-stored-auth", ["--method", "servers/list", "--list-stored-auth"]],
|
|
])(
|
|
"is rejected on the %s short-circuit path, which never reaches the lint",
|
|
async (_label, extra) => {
|
|
// These return from `parseArgs` before any connect, so a validation
|
|
// placed further down would let `--strict` be accepted and ignored.
|
|
await expect(
|
|
runCli(["node", "cli", "--cli", "--strict", ...extra]),
|
|
).rejects.toThrow("--strict requires --method tools/list.");
|
|
},
|
|
);
|
|
|
|
it("is rejected alongside --app-info rather than silently ignored", async () => {
|
|
// `tools/list --app-info` returns NDJSON straight from `runMethod` and
|
|
// never reaches `emitResult`, so accepting the pair would hand a CI caller
|
|
// a `--strict` gate that can never fail.
|
|
await expect(
|
|
runCli([
|
|
"node",
|
|
"cli",
|
|
"--cli",
|
|
"--method",
|
|
"tools/list",
|
|
"--strict",
|
|
"--app-info",
|
|
"--server-url",
|
|
"http://127.0.0.1:1/mcp",
|
|
]),
|
|
).rejects.toThrow("--strict cannot be combined with --app-info");
|
|
});
|
|
});
|