fix: stop the hoist dropping wrapper constraints, and let an explicit type decide

Copilot round 7: both findings are the same principle applied one level
further — JSON Schema siblings are conjunctive, and this code was treating
them as alternatives.

1. The hoist is a spread, so a branch keyword *replaced* the wrapper's. That
   is not a conjunction: a wrapper `enum: ["a"]` around a branch
   `enum: ["a", "b"]` means `"a"`, but the spread produced `["a", "b"]` and
   the dropdown would have offered — and submitted — a `"b"` the schema
   rejects. `type`, bounds, and the object keywords fail the same way.

   The collapse now declines when wrapper and branch carry different values
   for the same validation keyword, and the field renders through the JSON
   editor with its schema intact. Intersecting them properly is a much larger
   surface, and a subtly wrong intersection would be worse than none.
   Identical values are not a conflict, so the ordinary redundant-`type`
   shape still collapses; metadata (`title`, `description`, `default`,
   `enumNames`) never conflicts, since nothing validates against it.

2. `admitsNull` let a `{ type: "null" }` branch outvote an explicit
   non-null top-level `type`. `{ type: "string", anyOf: [..., { type:
   "null" }] }` rejects null — the value must satisfy the type *and* the
   union — so an explicit `type` now decides instead of falling through to
   the branch scan.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 19:26:14 -04:00
commit 987279b5d3

View file

@ -390,6 +390,70 @@ describe("normalizeNullableUnion", () => {
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// The hoist is a spread, but JSON Schema applies sibling keywords
// conjunctively — so a branch keyword replacing the wrapper's would *widen*
// the field. `enum: ["a"]` around a branch `enum: ["a","b"]` means "a", yet
// the spread would offer "b" in the dropdown and submit it.
it("declines when the branch would drop a conflicting wrapper enum", () => {
const schema = {
enum: ["a"],
anyOf: [{ type: "string", enum: ["a", "b"] }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when wrapper and branch disagree on type", () => {
const schema = {
type: "string",
anyOf: [{ type: "integer" }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when wrapper and branch disagree on bounds", () => {
const schema = {
minimum: 5,
anyOf: [{ type: "integer", minimum: 1 }, { type: "null" as const }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// Identical values are no conflict, so the ordinary redundant-type shape
// still collapses rather than being punished for being explicit.
it("collapses when wrapper and branch agree on a shared keyword", () => {
expect(
normalizeNullableUnion({
type: "string",
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
}),
).toEqual({
type: "string",
enum: ["a"],
anyOf: undefined,
// The explicit non-null `type` rules null out, conjunctively.
nullable: false,
});
});
it("treats wrapper metadata as safe to keep alongside branch constraints", () => {
expect(
normalizeNullableUnion({
title: "Direction",
description: "Which way",
default: "envio",
anyOf: [{ type: "string", enum: ["envio"] }, { type: "null" }],
}),
).toEqual({
title: "Direction",
description: "Which way",
default: "envio",
type: "string",
enum: ["envio"],
anyOf: undefined,
nullable: true,
});
});
it("ignores a schema with no union keywords at all", () => {
const schema = { type: "object" as const, properties: {} };
expect(normalizeNullableUnion(schema)).toBe(schema);
@ -470,6 +534,30 @@ describe("admitsNull", () => {
// A branch's enum is scoped to that branch, not a sibling of the union — this
// is the #1928 shape, and reading it as a sibling would call it non-nullable.
// An explicit `type` is conjunctive with the union, so it decides rather than
// merely offering another way to say yes. A `{ type: "null" }` branch cannot
// override a top-level `type: "string"`.
it("lets an explicit non-null type override a null branch", () => {
expect(
admitsNull({
type: "string",
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
}),
).toBe(false);
});
it("still honors an explicit type that does admit null", () => {
expect(
admitsNull({
type: ["string", "null"],
anyOf: [{ type: "string" }, { type: "null" }],
}),
).toBe(true);
expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe(
true,
);
});
it("is unaffected by an enum that lives inside an anyOf branch", () => {
expect(
admitsNull({