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:
parent
23698afbe5
commit
81f2115e8b
1 changed files with 312 additions and 6 deletions
|
|
@ -162,7 +162,7 @@ function propertiesOf(schema: RootUnionSchema): Record<string, unknown> {
|
|||
/**
|
||||
* Whether a branch is one a form can offer as an alternative.
|
||||
*
|
||||
* Three ways it is not, all of which would put an option in the picker that
|
||||
* Four ways it is not, all of which would put an option in the picker that
|
||||
* cannot be filled in:
|
||||
*
|
||||
* - **It carries no fields.** A `{ type: "null" }` member — the nullable
|
||||
|
|
@ -174,6 +174,12 @@ function propertiesOf(schema: RootUnionSchema): Record<string, unknown> {
|
|||
* - **Its `type` rules objects out.** Tool arguments are a JSON object, so a
|
||||
* `{ type: "string", properties: {…} }` member can never match — rendering it
|
||||
* as a fillable form would offer a call that cannot be valid.
|
||||
* - **It requires a name it never declares.** `required` is a list of names,
|
||||
* not of declarations, so `{ properties: { kind: … }, required: ["kind",
|
||||
* "payload"] }` is legal and says nothing about what `payload` accepts. Both
|
||||
* form builders enumerate `properties` alone, so no control is rendered for
|
||||
* it and the submit-time check reports it missing *permanently* — the same
|
||||
* dead end a `false`-schema required field produces (#2224).
|
||||
*/
|
||||
function isOfferable(
|
||||
branch: RootUnionSchema,
|
||||
|
|
@ -191,7 +197,19 @@ function isOfferable(
|
|||
// a perfectly ordinary way to say "one of these two" — and judging it on its
|
||||
// own properties would decline it, leaving the gate checking the base alone
|
||||
// and accepting `{}`, which the schema rejects.
|
||||
const properties = Object.values(propertiesOf(merged));
|
||||
const mergedProperties = propertiesOf(merged);
|
||||
// Judged on the MERGE for the same reason the fields are: a member may
|
||||
// require a name the root declares, which is exactly the
|
||||
// `anyOf: [{ required: ["email"] }, …]` shape above. `hasOwn` rather than
|
||||
// `in`, since an argument legally named `constructor` or `toString` would
|
||||
// otherwise resolve to the inherited one and read as declared.
|
||||
if (
|
||||
requiredOf(merged).some((name) => !Object.hasOwn(mergedProperties, name))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const properties = Object.values(mergedProperties);
|
||||
return (
|
||||
properties.length > 0 &&
|
||||
// Every value has to be something a renderer can read AND something a
|
||||
|
|
|
|||
Loading…
Reference in a new issue