fix: address Copilot review round 31 — proto-safe results, set-valued types, core keywords

- convertToolParameters defines its result keys rather than assigning them, so
  a discriminator legally named `__proto__` reaches the call instead of the
  prototype setter. It also collapses a nullable declaration before dispatching,
  so `type: ["number","null"]` coerces as a number rather than being sent
  as text.
- typeNameOf sorts an array `type`: JSON Schema reads it as a SET, so
  `["number","null"]` and `["null","number"]` are one declaration
  and must not read as branches disagreeing.
- nullableUnion's non-asserting keyword set covers `$defs`, `$id`,
  `$schema`, `$anchor`, `$dynamicAnchor` and `$vocabulary` — they
  identify or declare rather than assert. `$ref` stays out: it applies
  whatever it points at.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 12:25:46 -04:00
commit c9162d0e73
2 changed files with 87 additions and 11 deletions

View file

@ -245,6 +245,42 @@ describe("JSON Utils", () => {
});
});
it("keeps a converted argument named __proto__ (#2123)", () => {
const protoNamed: Tool = {
name: "proto-named",
inputSchema: {
type: "object",
properties: Object.fromEntries([["__proto__", { type: "number" }]]),
},
};
const converted = convertToolParameters(protoNamed, {
["__proto__"]: "3",
});
expect(Object.hasOwn(converted, "__proto__")).toBe(true);
});
it("reads an array type as a set when branches agree (#2123)", () => {
const setTyped: Tool = {
name: "set-typed",
inputSchema: {
type: "object",
anyOf: [
{
type: "object",
properties: { v: { type: ["number", "null"] }, a: {} },
},
{
type: "object",
properties: { v: { type: ["null", "number"] }, b: {} },
},
],
},
};
// The same declaration written in the other order — not a disagreement,
// so the coercion survives.
expect(convertToolParameters(setTyped, { v: "2" })).toEqual({ v: 2 });
});
it("leaves an ambiguously typed argument uncoerced (#2123)", () => {
const ambiguous: Tool = {
name: "ambiguous",
@ -293,8 +329,9 @@ describe("JSON Utils", () => {
],
},
};
// Both spell the type the same way, so it is not ambiguous.
expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: "2" });
// Both spell the type the same way, so it is not ambiguous — and the
// nullable declaration collapses to `number`, which is what coerces.
expect(convertToolParameters(arrayTyped, { v: "2" })).toEqual({ v: 2 });
});
it("ignores a malformed branch declaration when matching constants (#2123)", () => {

View file

@ -774,6 +774,16 @@ describe("admitsNull", () => {
expect(admitsNull({ const: null, anyOf: [false] })).toBe(false);
});
it("treats core identifier keywords as asserting nothing", () => {
// `$defs` declares subschemas for reference; it constrains nothing on its
// own, so this branch is unconstrained and admits null.
expect(admitsNull({ const: null, anyOf: [{ $defs: {} }] })).toBe(true);
// `$ref` is different — it applies whatever it points at.
expect(admitsNull({ const: null, anyOf: [{ $ref: "#/$defs/T" }] })).toBe(
false,
);
});
it("does not override a sibling that rejects null", () => {
// `const` is conjunctive with its siblings, not an override: both of
// these reject every value, so claiming nullability would let the