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:
cliffhall 2026-08-15 19:58:07 -04:00
commit 274a328abe

View file

@ -357,6 +357,26 @@ describe("normalizeNullableUnion", () => {
});
});
// The type-array path keeps the schema's own keywords, but `collapsed` clears
// `anyOf` and the renderers ignore the other applicators — so a compound
// schema would be widened into an unconstrained field rather than collapsed.
it("declines a type-array nullable that also carries a sibling anyOf", () => {
const schema = {
type: ["string", "null"],
anyOf: [{ const: "a" }],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines a type-array nullable that carries an opaque applicator", () => {
const withNot = { type: ["string", "null"], not: { const: "a" } };
expect(normalizeNullableUnion(withNot)).toBe(withNot);
const withOneOf = { type: ["string", "null"], oneOf: [{ const: "a" }] };
expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf);
const withAllOf = { type: ["string", "null"], allOf: [{ minLength: 2 }] };
expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf);
});
it("normalizes type array array|null", () => {
expect(normalizeNullableUnion({ type: ["array", "null"] })).toEqual({
type: "array",
@ -524,6 +544,17 @@ describe("admitsNull", () => {
).toBe(false);
});
it("ignores a null branch made unsatisfiable by its own nested anyOf", () => {
expect(
admitsNull({
anyOf: [
{ type: "string" },
{ type: "null", anyOf: [{ type: "string" }] },
],
}),
).toBe(false);
});
it("still accepts a plain null branch alongside an unsatisfiable one", () => {
expect(
admitsNull({
@ -588,14 +619,20 @@ describe("admitsNull", () => {
});
it("still honors an explicit type that does admit null", () => {
expect(admitsNull({ type: ["string", "null"] })).toBe(true);
expect(admitsNull({ type: "null" })).toBe(true);
});
// A sibling union is conjunctive with the `type`, so it can reject null even
// when the type list names it. It is not evaluated here, so its mere presence
// withholds the claim.
it("withholds the claim when a sibling anyOf sits beside an explicit type", () => {
expect(
admitsNull({
type: ["string", "null"],
anyOf: [{ type: "string" }, { type: "null" }],
}),
).toBe(true);
admitsNull({ type: ["string", "null"], anyOf: [{ type: "string" }] }),
).toBe(false);
// Unsatisfiable: names null, admits nothing.
expect(admitsNull({ type: "null", anyOf: [{ type: "string" }] })).toBe(
true,
false,
);
});