fix: stop const-less anyOf branches from crashing the form, and never select a non-string enum

Closes #2007.

Two things, both surfaced while answering "does #1928's PR also fix #2007?".

**#2007, and why it belongs in this PR.** `z.array(z.union([z.object(…),
z.object(…)]))` gives an `items.anyOf` whose branches carry no top-level
`const`, so every MultiSelect option was `String(item.const ?? "")` — the
empty string, twice. Mantine *throws* on duplicate option values, greying
out the whole tool panel.

That is a pre-existing bug, but this branch makes it reachable from a new
direction: a *nullable* array of object unions previously had no top-level
`type` and fell to the JSON fallback, and now collapses to `type: "array"`
with `items.anyOf` — straight into the crash. Shipping the collapse without
this fix would widen an existing crash, so the two go together. Both shapes
are covered by tests.

`toConstOptions` now returns `null` unless every branch yields a distinct,
non-empty value, and the field falls through to the JSON editor — which is
the honest widget for a union of object shapes anyway. Applied to the
`string`/`oneOf` path as well, which had the identical `?? ""` and the same
duplicate-throw waiting on it.

**Copilot round 2.** Round 1's fix kept "an explicit `type` is
authoritative", so `{ type: "number", enum: [1, 2] }` still collapses with
its enum hoisted. The TUI's dispatcher tests `property.enum` *before* the
type switch, and `toSelectOptions` stringifies, so that reached a select
that submits `"1"` for `1`. The select path is now gated on `isStringEnum`
(exported from core for this), and a typed non-string enum falls through to
its typed field: the enum constraint is lost, the value's type is not,
which is the safer of the two losses. Pre-existing for a plain numeric
enum; the collapse just made the nullable form reachable too.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 17:49:09 -04:00
commit 80eb321b14
2 changed files with 205 additions and 18 deletions

View file

@ -311,6 +311,39 @@ describe("schemaToForm", () => {
]);
});
// ink-form hands a select's stringified value straight back, so a numeric
// enum routed to a select would submit "1" for 1. The typed field loses the
// enum constraint but keeps the value's type — the safer loss.
it("routes a nullable numeric enum to its typed field, not a select", () => {
const form = schemaToForm(
{
properties: {
level: {
anyOf: [{ type: "integer", enum: [1, 2] }, { type: "null" }],
},
ratio: {
anyOf: [{ type: "number", enum: [0.5, 1.5] }, { type: "null" }],
},
},
},
"numericEnum",
);
expect(
form.sections[0]!.fields.map((f) => ({ name: f.name, type: f.type })),
).toEqual([
{ name: "level", type: "integer" },
{ name: "ratio", type: "float" },
]);
});
it("routes a plain numeric enum to its typed field too", () => {
const form = schemaToForm(
{ properties: { level: { type: "integer", enum: [1, 2] } } },
"plainNumericEnum",
);
expect(form.sections[0]!.fields[0]).toMatchObject({ type: "integer" });
});
it("leaves a union of two real types as a plain string field", () => {
const form = schemaToForm(
{

View file

@ -3,7 +3,10 @@
*/
import type { FormStructure, FormSection, FormField } from "ink-form";
import { normalizeNullableUnion } from "@inspector/core/json/nullableUnion.js";
import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
/** Minimal JSON Schema property shape used when building tool parameter forms */
interface JsonSchemaProperty {
@ -105,12 +108,17 @@ export function schemaToForm(
...baseField,
options: toSelectOptions(property.items.enum, property.items.enumNames),
} as FormField;
} else if (property.enum) {
// Single select
} else if (isStringEnum(property.enum)) {
// Single select. Gated on the members being strings because
// `toSelectOptions` stringifies them and ink-form hands the string
// straight back: a numeric `enum: [1, 2]` would submit `"1"` and violate
// the schema. A typed non-string enum falls through to its typed field
// below, which loses the enum constraint but keeps the value's type —
// the safer of the two losses.
field = {
type: "select",
...baseField,
options: toSelectOptions(property.enum, property.enumNames),
options: toSelectOptions(property.enum ?? [], property.enumNames),
} as FormField;
} else {
// Map JSON Schema types to ink-form types