fix: apply the fail-closed rule uniformly, and correct the admitsNull docs
Copilot round 9: four findings, all of them places where round 8's fail-closed
inversion was the right idea applied incompletely. The rule was only enforced
at the positions I happened to be editing; it now holds everywhere a schema is
inspected.
1. `admitsNull` returned early on an explicit `type`, ignoring a sibling
`anyOf` — but the two are conjunctive, so `{ type: ["string", "null"],
anyOf: [{ type: "string" }] }` admits no null at all. A sibling union now
withholds the claim.
2. A null-typed branch could be unsatisfiable via its own nested `anyOf`
(`{ type: "null", anyOf: [{ type: "string" }] }`). The branch check tested
`not`/`allOf`/`oneOf` but not `anyOf`; it now treats a nested union as
equally opaque.
3. The `type: [T, "null"]` collapse path had no applicator guard at all —
`collapsed` clears `anyOf` unconditionally and the renderers ignore the
rest, so `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` was widened
into an unconstrained string field. That path now declines the same way the
`anyOf` path does.
4. The `admitsNull` docstring still advertised `oneOf` support that round 8
deliberately removed. Rewritten to state what is recognized, what is
declined and why, and the asymmetry that settles the direction.
The three code fixes share one helper (`hasOpaqueApplicator`) rather than
repeating the condition, so the next position that needs it cannot drift.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
2fdbd1905f
commit
274a328abe
1 changed files with 97 additions and 18 deletions
|
|
@ -188,6 +188,23 @@ function typeNamesNull(type: unknown): boolean {
|
|||
return Array.isArray(type) ? type.includes("null") : type === "null";
|
||||
}
|
||||
|
||||
/**
|
||||
* Applicators this module does not evaluate, and which are **conjunctive** with
|
||||
* everything beside them — so any of them can rule `null` out, or constrain a
|
||||
* value in a way no widget here would honor.
|
||||
*
|
||||
* `anyOf` is excluded because it is the keyword being *recognized*: a caller
|
||||
* that has already identified the nullable-union shape checks it directly. Every
|
||||
* other position treats a stray `anyOf` as opaque too.
|
||||
*/
|
||||
function hasOpaqueApplicator(schema: NullableUnionSchema): boolean {
|
||||
return (
|
||||
schema.not !== undefined ||
|
||||
schema.allOf !== undefined ||
|
||||
schema.oneOf !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* What {@link stripNullEnumMembers} concluded about an `enum`.
|
||||
*
|
||||
|
|
@ -301,9 +318,24 @@ function collapsed<T extends NullableUnionSchema>(
|
|||
* 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.
|
||||
* **Answers `false` for anything it cannot fully evaluate**, which is a larger
|
||||
* set than it may look. Recognized: `nullable: true`, `type: "null"`,
|
||||
* `type: [..., "null"]`, and a `"null"` branch in an `anyOf` of any size.
|
||||
* Deliberately *not* recognized, despite naming null:
|
||||
*
|
||||
* - **`oneOf`** — it requires *exactly one* branch to match, so a `null` branch
|
||||
* does not mean `null` validates; two matching branches make it fail.
|
||||
* - **`not` / `allOf`**, at this level or on the branch, which can rule null out.
|
||||
* - **A sibling `anyOf` beside an explicit `type`**, since the two are
|
||||
* conjunctive and the union is not evaluated here.
|
||||
* - **A branch that names null but is unsatisfiable**, whether by its own
|
||||
* `enum`/`const` or its own nested applicators.
|
||||
*
|
||||
* The asymmetry is what settles the direction: under-claiming costs a clear
|
||||
* button and treats a valid `null` as missing, while over-claiming lets the
|
||||
* form emit a value the schema rejects. So callers get a firm "no" rather than
|
||||
* an optimistic guess, and a schema this module cannot read renders through the
|
||||
* JSON editor with its constraints intact.
|
||||
*/
|
||||
export function admitsNull(schema: NullableUnionSchema): boolean {
|
||||
if (nullExcludedBySiblings(schema)) {
|
||||
|
|
@ -315,11 +347,7 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
|||
// `null` validates — two matching branches make it fail. Rather than answer
|
||||
// these half-correctly, say no: under-claiming nullability costs a clear
|
||||
// button, while over-claiming lets the form emit a value the schema rejects.
|
||||
if (
|
||||
schema.not !== undefined ||
|
||||
schema.allOf !== undefined ||
|
||||
schema.oneOf !== undefined
|
||||
) {
|
||||
if (hasOpaqueApplicator(schema)) {
|
||||
return false;
|
||||
}
|
||||
if (schema.nullable === true) {
|
||||
|
|
@ -331,6 +359,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
|||
// as the union. Falling through to the branch scan here would let the union
|
||||
// override a constraint that outranks it.
|
||||
if (schema.type !== undefined) {
|
||||
// A sibling `anyOf` is conjunctive with the `type`, so it can reject null
|
||||
// even when the type list names it — `{ type: ["string", "null"], anyOf:
|
||||
// [{ type: "string" }] }` admits no null at all. Not evaluated here, so
|
||||
// its mere presence withholds the claim.
|
||||
if (schema.anyOf !== undefined) {
|
||||
return false;
|
||||
}
|
||||
return typeNamesNull(schema.type);
|
||||
}
|
||||
return (
|
||||
|
|
@ -345,9 +380,10 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
|||
const branchSchema = branch as NullableUnionSchema;
|
||||
return (
|
||||
!nullExcludedBySiblings(branchSchema) &&
|
||||
branchSchema.not === undefined &&
|
||||
branchSchema.allOf === undefined &&
|
||||
branchSchema.oneOf === undefined
|
||||
!hasOpaqueApplicator(branchSchema) &&
|
||||
// A nested union inside the branch is as opaque as the others: `{ type:
|
||||
// "null", anyOf: [{ type: "string" }] }` names null and admits nothing.
|
||||
branchSchema.anyOf === undefined
|
||||
);
|
||||
}) ?? false
|
||||
);
|
||||
|
|
@ -408,7 +444,13 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
|||
if (
|
||||
Array.isArray(schema.type) &&
|
||||
schema.type.length === 2 &&
|
||||
schema.type.includes("null")
|
||||
schema.type.includes("null") &&
|
||||
// This path keeps the schema's own keywords, but `collapsed` clears `anyOf`
|
||||
// and the renderers ignore the other applicators — so a compound schema
|
||||
// like `{ type: ["string", "null"], anyOf: [{ const: "a" }] }` would be
|
||||
// widened into an unconstrained string field. Leave it for the JSON editor.
|
||||
schema.anyOf === undefined &&
|
||||
!hasOpaqueApplicator(schema)
|
||||
) {
|
||||
const type = schema.type.find((member) => member !== "null");
|
||||
if (isRenderableType(type)) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue