fix: address Copilot review on the nullable-union collapse

Four findings, all valid:

1. Returning `T` was unsound. The collapse genuinely changes two fields —
   `type` stops being an array, `anyOf` becomes `undefined` — so a caller
   whose `T` typed either that way could keep treating it so afterwards.
   The result is now `T | NormalizedNullableUnion<T>`, which states exactly
   that: `Omit<T, "type" | "anyOf">` plus a `RenderableType` `type`, a
   cleared `anyOf`, and `nullable`. The one assertion left is isolated in a
   `collapsed()` helper, needed only because `Omit` stays deferred while `T`
   is open.

2. A typeless `enum` does not imply strings. JSON Schema allows
   `enum: [1, 2]`, and inferring `"string"` for it would hand numbers to a
   `Select` declared `string[]`, and make the TUI submit `"1"` for `1`. Now
   only an all-string enum earns the inference; anything else stays on the
   JSON fallback. An explicit `type` is still authoritative.

3. `hasMissingRequiredFields` counted every null as missing, so clearing a
   *required* nullable enum — newly possible via the clear button — would
   disable submit on a value the schema permits. `required` constrains
   presence, not content, so null now counts as present exactly when the
   field's schema admits it.

4. `collectSchemaDefaults` read the raw union, so a default nested inside a
   nullable object's branch was displayed by the form but never seeded into
   the submitted values. It normalizes each property first, like the
   renderer does.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 17:24:46 -04:00
commit e4e16bb3e6

View file

@ -144,6 +144,39 @@ describe("normalizeNullableUnion", () => {
});
});
// JSON Schema's `enum` is untyped, so a typeless branch only implies strings
// when every member is one. Guessing otherwise would hand a number to a
// renderer that declared the option list `string[]`.
it("does not infer string for a typeless non-string enum branch", () => {
const schema = { anyOf: [{ enum: [1, 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless mixed enum branch", () => {
const schema = { anyOf: [{ enum: ["a", 2] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("does not infer string for a typeless empty enum branch", () => {
const schema = { anyOf: [{ enum: [] }, { type: "null" as const }] };
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// An explicit `type` is authoritative — the enum members are the server's
// problem at that point, not an inference this function is making.
it("still collapses a non-string enum branch that declares its type", () => {
expect(
normalizeNullableUnion({
anyOf: [{ type: "number", enum: [1, 2] }, { type: "null" }],
}),
).toEqual({
type: "number",
enum: [1, 2],
anyOf: undefined,
nullable: true,
});
});
it("infers string for a typeless enum branch", () => {
expect(
normalizeNullableUnion({