fix: address Copilot review rounds 7 and 8

- rootUnion: a `oneOf` demands that EXACTLY one alternative match, which
  flattening cannot preserve, so it is offered only when a discriminator makes
  the alternatives mutually exclusive; `anyOf` makes no such claim and is
  unaffected. An empty `additionalProperties` schema is the equivalent of
  `true` and no longer declines a union.
- selectBranchIndex: falls back to the branch whose own required fields the
  values supply, so an undiscriminated union's values open the picker on the
  shape they describe rather than on the first branch.
- admitsNull: honors a sibling `nullable: true` beside `const: null`.
- SchemaForm: a value is carried only where the incoming branch leaves the
  root's declaration as it found it; anything it declares itself is reset,
  since it may type the name differently from where the value was typed.
  `hasOwn`/`fromEntries` throughout, so `constructor` is not read as a
  supplied value and `__proto__` is not dropped into the prototype setter.
- TUI: the same prototype-safe construction on the way out of the form, and
  the chosen branch's `required` list is checked at submit — rendering a
  branch's fields optional makes the FORM satisfiable, not the call, so the
  missing names are reported rather than a known-invalid call being sent.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 03:54:24 -04:00
commit 85b8e4601c
2 changed files with 492 additions and 42 deletions

View file

@ -397,7 +397,10 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
// 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);
return (
schema.anyOf === undefined &&
(schema.nullable === true || typeAdmitsNull(schema.type))
);
}
if (schema.nullable === true) {

View file

@ -271,6 +271,34 @@ function conflictsWithBase(
);
}
/**
* Whether some property discriminates the alternatives: declared by **every**
* member, pinned by each to a `const`, and pinned to a *different* one by each.
* That is what makes at most one alternative matchable, which is the constraint
* `oneOf` states and flattening cannot otherwise keep.
*
* When the schema names a `discriminator`, only that property is considered —
* the author has said which one carries the distinction.
*/
function hasDiscriminator(
members: RootUnionSchema[],
named: string | undefined,
): boolean {
const first = propertiesOf(members[0] ?? {}) ?? {};
const candidates = named !== undefined ? [named] : Object.keys(first);
return candidates.some((name) => {
const constants = members.map((member) => {
const property = toBranch((propertiesOf(member) ?? {})[name]) as {
const?: unknown;
} | null;
return property?.const;
});
if (constants.some((value) => value === undefined)) return false;
const seen = new Set(constants.map((value) => JSON.stringify(value)));
return seen.size === constants.length;
});
}
/**
* Every property name the schema's composition members declare, whether or not
* the composition could be flattened.
@ -441,6 +469,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
if (schema.oneOf !== undefined && schema.anyOf !== undefined) {
return { base, branches: [] };
}
const isExclusiveUnion = schema.oneOf !== undefined;
const members = schema.oneOf ?? schema.anyOf ?? [];
const branches = members.map(toBranch);
if (
@ -468,7 +497,12 @@ export function resolveRootUnion<T extends RootUnionSchema>(
const additional = base.additionalProperties;
const restrictsAdditional =
additional === false ||
(typeof additional === "object" && additional !== null);
// An EMPTY schema object is the JSON Schema equivalent of `true` — it
// constrains nothing, so it is no reason to decline. Only a schema that
// states something is.
(typeof additional === "object" &&
additional !== null &&
Object.keys(additional).length > 0);
const baseNames = propertiesOf(base) ?? {};
if (
restrictsAdditional &&
@ -481,6 +515,22 @@ export function resolveRootUnion<T extends RootUnionSchema>(
return { base, branches: [] };
}
// `oneOf` demands that **exactly one** alternative match, which flattening
// cannot preserve: the merged branches are offered as if any of them would
// do. That is only safe when the alternatives are mutually exclusive by
// construction — a discriminator, i.e. some property every branch pins to a
// `const` of its own. Without one, two branches can accept the same
// arguments, and a call the form calls valid is one the server refuses.
if (
isExclusiveUnion &&
!hasDiscriminator(
branches as RootUnionSchema[],
schema.discriminator?.propertyName,
)
) {
return { base, branches: [] };
}
const discriminatorProperty = schema.discriminator?.propertyName;
return {
base,
@ -533,5 +583,25 @@ export function selectBranchIndex<T extends RootUnionSchema>(
matches.push(index);
}
});
return matches.length === 1 ? matches[0] : null;
if (matches.length === 1) return matches[0];
if (matches.length > 1) return null;
// No discriminator settled it. A union need not have one, and values still
// belong to a shape — so fall back to the branch whose own required fields
// the values supply, when exactly one branch's do. Without this, values for
// an undiscriminated branch open the picker on the first branch while the
// required-field gate (which accepts *any* satisfied branch) lets them be
// submitted, so the form shows one shape and sends another.
if (Object.keys(values).length === 0) return null;
const satisfied = branches.filter((branch) => {
const required = branch.schema.required ?? [];
const own = required.filter((name) => branch.declaredFields.includes(name));
return (
own.length > 0 &&
own.every(
(name) => Object.hasOwn(values, name) && values[name] !== undefined,
)
);
});
return satisfied.length === 1 ? branches.indexOf(satisfied[0]) : null;
}