fix: address Copilot review round 5 — union member faithfulness, partial discriminators, const-null siblings

- rootUnion: union members get the same flattenability test as allOf members,
  so a branch whose nested `allOf: [false]` makes it unsatisfiable is no
  longer offered as a callable alternative with that constraint erased.
- selectBranchIndex: weigh only the pinned constants the caller SUPPLIED. One
  it did not supply is a value this identification exists to seed, so
  requiring it meant a deep link naming `kind` alone matched no branch
  whenever the branches also pinned, say, a `version`. A supplied constant
  that disagrees is still evidence against.
- admitsNull: `const: null` is conjunctive with its siblings, not an
  override — `{ type: "string", const: null }` and
  `{ const: null, anyOf: [...] }` reject every value, so the claim is made
  only where the type and a sibling union leave null on the table.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 02:38:14 -04:00
commit f556f8831b
2 changed files with 103 additions and 14 deletions

View file

@ -368,17 +368,15 @@ function collapsed<T extends NullableUnionSchema>(
* an optimistic guess, and a schema this module cannot read renders through the
* JSON editor with its constraints intact.
*/
/** Whether an absent or null-naming `type` leaves null on the table. */
function typeAdmitsNull(type: string | string[] | undefined): boolean {
return type === undefined || typeNamesNull(type);
}
export function admitsNull(schema: NullableUnionSchema): boolean {
if (nullExcludedBySiblings(schema)) {
return false;
}
// `const: null` admits null and nothing else, whatever the schema says (or
// omits) about `type`. Without this a required field pinned to null is seeded
// with the only value it accepts and then reported as missing, leaving submit
// permanently disabled on a form the user cannot change (#2123).
if (schema.const === null) {
return true;
}
// Applicators this module does not evaluate. `not: { type: "null" }` rules
// null out; `allOf` can add a member that does; and `oneOf` requires
// **exactly one** branch to match, so a null branch does not by itself mean
@ -388,6 +386,20 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
if (hasOpaqueApplicator(schema)) {
return false;
}
// `const: null` admits null and nothing else — the case a required field
// pinned to null needs, or it is seeded with the only value it accepts and
// then reported missing, leaving submit permanently disabled on a field the
// user cannot change (#2123).
//
// Claimed only where the siblings agree, since `const` is conjunctive with
// them rather than an override: `{ type: "string", const: null }` and
// `{ const: null, anyOf: [...] }` reject every value, and saying otherwise
// would let the gate accept a `null` the schema forbids. The opaque
// applicators (`not`, `allOf`, `oneOf`) are already refused above.
if (schema.const === null) {
return schema.anyOf === undefined && typeAdmitsNull(schema.type);
}
if (schema.nullable === true) {
return true;
}

View file

@ -430,6 +430,11 @@ export function resolveRootUnion<T extends RootUnionSchema>(
(branch) =>
branch === null ||
!isOfferable(branch) ||
// The same faithfulness test the `allOf` fold applies: a member
// carrying a constraint the merge does not copy — a nested `allOf`, a
// `not`, a `$ref` — would have it erased along with the union keyword,
// which can present an unsatisfiable branch as a callable one.
!isFlattenable(branch) ||
conflictsWithBase(base, branch),
)
) {
@ -476,9 +481,14 @@ export function selectBranchIndex<T extends RootUnionSchema>(
] as const,
)
.filter(([, constValue]) => constValue !== undefined);
// Only the pinned names the caller actually supplied are evidence. A
// constant it did not supply is one this identification exists to *seed* —
// requiring it would mean a deep link naming `kind` alone matched no branch
// whenever the branches also pin, say, a `version`.
const supplied = pinned.filter(([name]) => values[name] !== undefined);
if (
pinned.length > 0 &&
pinned.every(([name, constValue]) => values[name] === constValue)
supplied.length > 0 &&
supplied.every(([name, constValue]) => values[name] === constValue)
) {
matches.push(index);
}