fix: address Copilot review round 16 — tie-breaking, const-aware gating, malformed declarations

- selectBranchIndex no longer stops when several branches share the supplied
  constant. A branch is dropped only when a supplied value CONTRADICTS one of
  its constants; the required-field and exclusive-name passes then run over the
  remaining candidates, so { version: 1, phone: "555" } resolves to the SMS
  branch instead of falling back to the first.
- hasMissingRequiredFields counts only the branches the values could be making
  (new branchAcceptsValues): { kind: "sms", address: "x" } supplies
  everything the EMAIL branch requires while contradicting its discriminator,
  and would otherwise enable a submit the server refuses.
- convertToolParameters ignores a malformed property declaration when branches
  agree on a type, rather than storing a non-schema as the coercion schema.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 06:23:54 -04:00
commit 7792ad71e9
4 changed files with 133 additions and 43 deletions

View file

@ -269,6 +269,27 @@ describe("JSON Utils", () => {
});
});
it("ignores a malformed branch declaration when agreeing on a type (#2123)", () => {
const malformedDeclaration: Tool = {
name: "malformed-declaration",
inputSchema: {
type: "object",
anyOf: [
{ type: "object", properties: { count: null as unknown, a: {} } },
{
type: "object",
properties: { count: { type: "number" }, b: {} },
},
],
},
};
// The `null` is not a vote about the type, and must not end up standing
// in for one — the surviving declaration is what coerces.
expect(
convertToolParameters(malformedDeclaration, { count: "3" }),
).toEqual({ count: 3 });
});
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
const allOfTool: Tool = {
name: "allof-tool",

View file

@ -811,6 +811,30 @@ describe("resolveRootUnion", () => {
expect(selectBranchIndex(pinnedOnInherited, { x: "supplied" })).toBe(0);
});
it("keeps looking when several branches share the supplied constant", () => {
// Both pin `version`, so that constant settles nothing — but `phone`
// belongs to one branch alone and does.
const versioned = resolveRootUnion({
type: "object",
anyOf: [
{
type: "object",
properties: {
version: { const: 1 },
address: { type: "string" },
},
},
{
type: "object",
properties: { version: { const: 1 }, phone: { type: "string" } },
},
],
}).branches;
expect(selectBranchIndex(versioned, { version: 1, phone: "555" })).toBe(
1,
);
});
it("reports none when the values identify nothing", () => {
expect(selectBranchIndex(branches, {})).toBeNull();
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();

View file

@ -493,6 +493,15 @@ describe("root composition (#2123)", () => {
expect(hasMissingRequiredFields(UNION, { kind: "email" })).toBe(true);
});
it("does not count a branch whose discriminator the values contradict", () => {
// `{ kind: "sms", address: … }` supplies everything the EMAIL branch
// requires while carrying a `kind` that branch rejects — and the SMS
// branch it does name is still missing `phone`.
expect(
hasMissingRequiredFields(UNION, { kind: "sms", address: "a@b.c" }),
).toBe(true);
});
it("allows submission once one branch is satisfied", () => {
expect(hasMissingRequiredFields(UNION, { kind: "sms", phone: "555" })).toBe(
false,

View file

@ -3,6 +3,7 @@ import {
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import {
branchAcceptsValues,
resolveRootUnion,
selectBranchIndex,
} from "@inspector/core/json/rootUnion.js";
@ -266,7 +267,14 @@ export function hasMissingRequiredFields(
// direction that matters: it never blocks arguments the schema accepts.
const { base, branches } = resolveRootUnion(schema);
if (branches.length > 0) {
return branches.every((branch) => hasMissingIn(branch.schema, values));
// Only the branches the values could actually be making are considered: a
// required-name count alone would let `{ kind: "sms", address: "x" }` look
// like a complete *email* call, whose discriminator it contradicts.
const applicable = branches.filter((branch) =>
branchAcceptsValues(branch, values),
);
if (applicable.length === 0) return true;
return applicable.every((branch) => hasMissingIn(branch.schema, values));
}
return hasMissingIn(base, values);
}