fix: address Copilot review round 3 — typed consts, null admission, preset coverage

Three findings (one inline, two in the suppressed block), all valid.

1. `toConstOptions` still stringified every non-null `const`, so
   `items: { anyOf: [{ const: 1 }, { const: 2 }] }` submitted `["1"]` where
   the schema says `[1]`. Same wrong-type-on-the-wire problem that keeps a
   numeric `enum` off the select path, so it gets the same answer: only a
   string `const` is selectable, anything else falls to the JSON editor
   where the value keeps its type.

2. `hasMissingRequiredFields` derived null admission from the *renderer's*
   collapse, which is narrower on purpose — it only flattens a two-member
   union. So `anyOf: [string, number, null]` renders through the JSON
   fallback (where `null` is typeable) yet was still marked missing,
   disabling submit on a value the schema accepts. Null admission is a
   validity question, not a rendering one, so it is now its own predicate:
   `admitsNull` in core, recognizing `nullable`, `type: "null"`,
   `type: [..., "null"]`, and a null branch anywhere in an `anyOf`/`oneOf`
   of any size.

3. Nothing exercised the `record_shipment` preset — the web and TUI tests
   build the `anyOf` shape by hand, which verifies the renderers but
   *assumes* the premise the whole fix rests on: that Zod's `.nullish()`
   emits `anyOf: [<branch>, { type: "null" }]` with the enum on the branch.
   A Zod output change or a registry typo would leave every unit test green
   while the documented reproduction server quietly stopped reproducing.
   Added an integration test that boots the preset over HTTP, asserts the
   wire shape, and runs it back through the collapse — following the
   `duplicate-tool-names.test.ts` precedent, which exists for the same
   reason (#1957).

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 18:15:38 -04:00
commit 34ba8256a4

View file

@ -42,6 +42,10 @@ export interface NullableUnionSchema {
type?: string | string[];
enum?: unknown[];
anyOf?: readonly unknown[];
// Read only by `admitsNull`, which considers encodings the collapse itself
// declines to flatten.
oneOf?: readonly unknown[];
nullable?: boolean;
}
/** Narrow an `anyOf` member to a readable object, or `null` if it isn't one. */
@ -120,6 +124,46 @@ function collapsed<T extends NullableUnionSchema>(
} as NormalizedNullableUnion<T>;
}
/**
* Whether a schema permits an explicit `null`.
*
* Deliberately **independent of {@link normalizeNullableUnion}**, which is a
* *renderer* question — "can this become one widget?" — and is therefore
* narrower on purpose: it only collapses a two-member union. Null admission is
* a *validity* question and has no such limit, so
* `anyOf: [{ type: "string" }, { type: "number" }, { type: "null" }]` admits
* null even though it renders through the JSON fallback. Deriving one from the
* other would make a form reject a value its own schema accepts, which is why
* these are two functions rather than a `.nullable` flag read off the collapse.
*
* Recognizes every encoding the collapse does, plus the ones it declines:
* `nullable: true`, `type: "null"`, `type: [..., "null"]`, and a `"null"`
* branch anywhere in an `anyOf` or `oneOf` of any size.
*/
export function admitsNull(schema: NullableUnionSchema): boolean {
if (schema.nullable === true) {
return true;
}
if (schema.type === "null") {
return true;
}
if (Array.isArray(schema.type) && schema.type.includes("null")) {
return true;
}
return [schema.anyOf, schema.oneOf].some((branches) =>
branches?.some((entry) => {
const branch = toBranch(entry);
if (branch === null) {
return false;
}
return (
branch.type === "null" ||
(Array.isArray(branch.type) && branch.type.includes("null"))
);
}),
);
}
/**
* Collapse a nullable union — what Zod's `.nullish()` / `.nullable()` and
* FastMCP's optional arguments emit — into `{ type: <T>, nullable: true }`.