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

View file

@ -703,18 +703,27 @@ export function SchemaForm({
// incoming branch pins to a `const` is never carried: the branches of a
// discriminated union share the discriminator's *name* and disagree about
// its value.
const outgoing = new Set(activeBranch?.declaredFields ?? []);
const incoming = new Set(nextBranch.declaredFields);
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
if (
values[name] !== undefined &&
fieldSchema.const === undefined &&
!(outgoing.has(name) && incoming.has(name))
) {
carried[name] = values[name];
}
}
// `hasOwn` and `fromEntries`, never `values[name]` on its own or an
// assignment: `constructor` is a legal argument name whose inherited value
// would otherwise be read as one the user supplied, and `__proto__` is one
// an assignment would drop into the legacy prototype setter.
const carried = Object.fromEntries(
Object.entries(nextProperties)
.filter(
([name, fieldSchema]) =>
Object.hasOwn(values, name) &&
values[name] !== undefined &&
fieldSchema.const === undefined &&
// Carried only where the incoming branch leaves the root's
// declaration as it found it. Anything the incoming branch declares
// itself is reset: it may type the name differently from wherever
// the value was typed, so a `3` from a number field would otherwise
// land in its checkbox.
!incoming.has(name),
)
.map(([name]) => [name, values[name]]),
);
onChange({ ...collectSchemaDefaults(nextBranch.schema), ...carried });
}