fix: decline a union branch requiring a name it never declares (#2224)

`isOfferable` in `core/json/rootUnion.ts` checked that a merged branch carries
at least one property and that every property value is readable, but never that
each name in `required` resolves to a property a form can render. A branch like
`{ properties: { kind }, required: ["kind", "payload"] }` was therefore offered
in the picker, rendered no control for `payload`, and had the submit-time check
report it missing permanently — the exact dead end the function's own comment
already reasons through for a `false`-schema required field, one case short.

Judge the merge, so a branch requiring only names the ROOT declares —
`anyOf: [{ required: ["email"] }, …]` — stays offerable as before. `hasOwn`
rather than `in`, since an argument legally named `constructor` would otherwise
resolve through the prototype and read as declared.

Declining every branch of a union leaves the web form with neither a picker nor
a field, which is a different dead end. `SchemaForm` now seeds the raw-JSON
editor (#2151) in that one case — a root union that resolved to no branches and
no properties — so the arguments stay expressible. Seeded rather than forced, so
the switch still works, and re-seeded during render (never in an effect) because
the form is reused across tools rather than remounted. A tool that genuinely
takes no arguments is untouched. The TUI has no such editor; there the union is
simply not offered, which is already better than a section that can never be
submitted.

Adds a `record_shipment_by` preset and wires it into the
`root-union-schemas-http.json` showcase so the shape is reproducible by hand,
with docs/test-servers.md covering it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PxYT4eKdnx1yRw7x4bW7Ts
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-09-06 12:59:52 -04:00
commit 81f2115e8b

View file

@ -809,6 +809,84 @@ describe("resolveRootUnion", () => {
expect(branches).toHaveLength(2);
});
it("declines a member requiring a name nothing declares", () => {
// `required` is a list of names, not of declarations. Both form builders
// enumerate `properties` alone, so `payload` gets no control while the
// submit gate reports it missing forever — the picker offers an option
// that can never be completed (#2224).
const { branches } = resolveRootUnion({
type: "object",
anyOf: [
{
type: "object",
properties: { kind: { const: "a" } },
required: ["kind", "payload"],
},
SMS,
],
});
expect(branches).toEqual([]);
});
it("declines a member requiring a name only ANOTHER member declares", () => {
// Branches are alternatives, not a conjunction: a name the sibling
// declares is not one this branch can render, so the dead end is the
// same one.
const { branches } = resolveRootUnion({
type: "object",
anyOf: [
{
type: "object",
properties: { kind: { const: "a" } },
required: ["kind", "phone"],
},
SMS,
],
});
expect(branches).toEqual([]);
});
it("offers a member requiring a name it inherits from the root", () => {
// The merge is what is judged, so a member requiring a name the ROOT
// declares stays offerable — the `anyOf: [{ required: ["email"] }, …]`
// shape above must not regress.
const { branches } = resolveRootUnion({
type: "object",
properties: { token: { type: "string" } },
anyOf: [
{
type: "object",
properties: { a: { type: "string" } },
required: ["token"],
},
{
type: "object",
properties: { b: { type: "string" } },
required: ["token"],
},
],
});
expect(branches).toHaveLength(2);
});
it("does not read an inherited name as a declared property", () => {
// An argument legally named `constructor` resolves through the prototype
// on a plain object, so a membership test that is not `hasOwn` would call
// this branch offerable and render nothing for it.
const { branches } = resolveRootUnion({
type: "object",
anyOf: [
{
type: "object",
properties: { kind: { const: "a" } },
required: ["constructor"],
},
SMS,
],
});
expect(branches).toEqual([]);
});
it("declines a member carrying a `false` property schema", () => {
// `false` admits no value at all, so the field can never be filled — and
// a required one makes the branch unsatisfiable.