Copilot review round 6.
- Share one traversal between `walk` and `declaresAnyId`. The `$id` guard
added last round descended through EVERY object value, including
instance data — `examples: [{"$id": "x"}]`, a `default`, a `const` —
where `$id` is a payload key declaring no embedded resource. Reading
one there suppressed `remote-ref` for the whole document, turning last
round's false-positive fix into a blanket false negative. Both passes
now go through `forEachSubschema`, driven by the same keyword tables,
so they cannot drift about what counts as a schema position.
- Await the stderr writes. `writeSchemaLintReport` used a bare
`process.stderr.write`, but both CLI exit paths call `process.exit()`
as soon as the work returns, which discards anything still buffered on
a piped stderr — truncating or losing the multi-block `--strict`
report on exactly the redirected-output runs a CI caller uses. Added
`awaitableError` beside the existing `awaitableLog` and awaited it.
The test's fake defers its callback so a synchronous one cannot make
the assertion vacuous; two existing stderr stubs that never invoked
their callbacks were fixed in the same pass.
- Show the CLI README's sample report in full. It printed one error
block immediately followed by the summary, a shape the formatter
cannot produce — every finding gets a block. Captured from a real run.
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>
318 lines
10 KiB
TypeScript
318 lines
10 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, ...rest: unknown[]): boolean => {
|
|
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
|
(
|
|
rest.find((r) => typeof r === "function") as (() => void) | undefined
|
|
)?.();
|
|
return true;
|
|
}) as typeof process.stderr.write;
|
|
});
|
|
|
|
afterEach(() => {
|
|
process.stderr.write = originalWrite;
|
|
});
|
|
|
|
it("writes nothing at all when there are no findings", async () => {
|
|
await writeSchemaLintReport([], true);
|
|
expect(stderr).toBe("");
|
|
});
|
|
|
|
it("writes a one-line hint without --strict", async () => {
|
|
await 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", async () => {
|
|
await 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");
|
|
});
|
|
|
|
it("resolves only once stderr has taken the write", async () => {
|
|
// Both CLI exit paths call `process.exit()` as soon as the work returns,
|
|
// which discards anything still buffered on a piped stderr. The report is
|
|
// only safe if this settles on the write callback rather than fire-and-
|
|
// forget, so the fake defers its callback to a later tick.
|
|
let flushed = false;
|
|
process.stderr.write = ((
|
|
chunk: unknown,
|
|
cb?: (err?: Error | null) => void,
|
|
): boolean => {
|
|
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
|
setTimeout(() => {
|
|
flushed = true;
|
|
cb?.();
|
|
}, 0);
|
|
return false;
|
|
}) as typeof process.stderr.write;
|
|
|
|
await writeSchemaLintReport(lintListResult(DIRTY_LIST), true);
|
|
expect(flushed).toBe(true);
|
|
});
|
|
});
|
|
|
|
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, ...rest: unknown[]): boolean => {
|
|
stderr += typeof chunk === "string" ? chunk : String(chunk);
|
|
(
|
|
rest.find((r) => typeof r === "function") as (() => void) | undefined
|
|
)?.();
|
|
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");
|
|
});
|
|
});
|