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:
parent
86ba42633c
commit
987279b5d3
1 changed files with 166 additions and 6 deletions
|
|
@ -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({
|
||||
|
|
|
|||
Loading…
Reference in a new issue