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:
parent
c15e30414f
commit
85b8e4601c
11 changed files with 492 additions and 42 deletions
|
|
@ -199,7 +199,10 @@ v2/main/
|
|||
│ │ # SchemaForm (the Variant picker + the branch-change
|
||||
│ │ # value pruning), TUI schemaToForm (a section per
|
||||
│ │ # branch, its fields forced OPTIONAL since only one
|
||||
│ │ # alternative applies — and rendered under PREFIXED
|
||||
│ │ # alternative applies — the chosen branch's
|
||||
│ │ # `required` is then checked at SUBMIT
|
||||
│ │ # (missingRequiredFields), since optional fields make
|
||||
│ │ # the FORM satisfiable, not the call — and rendered under PREFIXED
|
||||
│ │ # names behind a variant select, because ink-form
|
||||
│ │ # keys values by field name across the WHOLE form,
|
||||
│ │ # so two branches' `kind` would otherwise be one
|
||||
|
|
|
|||
|
|
@ -300,7 +300,7 @@ Open the Tools tab and select `echo`. Above the fields is a **Variant** picker l
|
|||
|
||||
Switching branches drops the values that belonged to the outgoing one. They are no longer on screen, so the user can neither see nor clear them, and submitting them would describe a shape the call is not making.
|
||||
|
||||
The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, preceded by a **Variant** select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted.
|
||||
The **TUI** has the same gap and is worth checking against the same server (`--tui`, then test `echo`). ink-form is static — there is no picker to hide the alternatives behind — so each branch becomes its own **section**, preceded by a **Variant** select naming which one the call means. The fields in a branch section are rendered optional whatever the branch says: only one alternative applies to a call, so requiring them would build a form that can never be submitted. That makes the *form* satisfiable, not the call, so the chosen branch's own `required` list is checked at submit and reported — never sent as a call already known to violate the schema.
|
||||
|
||||
The sections are not as independent as they look, which is why the select is not cosmetic: ink-form keeps one value object for the whole form, keyed by field name alone, so two branches both declaring `kind` would be **one** field and the later section's initial value would decide what the earlier one submits. Each branch's fields are therefore rendered under a prefixed name and translated back on submit, where every branch but the chosen one is dropped.
|
||||
|
||||
|
|
@ -313,7 +313,8 @@ What it declines to flatten is as deliberate as what it flattens, and every case
|
|||
- **A union whose members are not all field-carrying object schemas** — including one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A picker whose options render nothing is no better than no picker.
|
||||
- **A branch that restates a constraint the root already states.** The two are conjunctive, so root `minimum: 10` under branch `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable, and `type: "string"` under `type: "number"` describes a value that cannot exist — rendering either side would accept what the schema rejects. A property both declare *compatibly* is merged rather than replaced, so a root's `minimum` survives a branch's `maximum`, and a disagreement about `title`/`description` is not a conflict at all.
|
||||
- **A composition member stating anything the merge cannot apply.** Only `type`, `properties` and `required` are folded in, so a member carrying a nested `allOf`/`anyOf`, a `not`, an `additionalProperties`, or a `$ref` would have that constraint erased along with the keyword — turning an unsatisfiable schema (`allOf: [false, …]` admits nothing) into a fillable form. `allOf` members are checked against the accumulated merge rather than the root alone, so two of them contradicting each other is caught even when neither contradicts the root.
|
||||
- **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed.
|
||||
- **A `oneOf` whose alternatives are not mutually exclusive.** `oneOf` demands that *exactly one* alternative match, which flattening cannot preserve — the branches are offered as if any would do. It is only safe with a discriminator (a property every branch pins to a `const` of its own), so an undiscriminated `oneOf` is declined. `anyOf` makes no such claim and is offered either way.
|
||||
- **A union that adds fields under a restrictive root `additionalProperties`.** That keyword constrains whatever its *sibling* `properties` does not name, so a root `additionalProperties: false` rejects every field the branches add — flattening would move them beside the keyword, where they read as allowed. An empty schema (`{}`) constrains nothing and is treated as permissive.
|
||||
- **A schema carrying both `oneOf` and `anyOf`** — independent keywords a value satisfies *together*, not two spellings of one union, so reading one and dropping the other omits real constraints while looking complete. Satisfying both honestly means the cross product of their alternatives, which no real schema has yet asked for.
|
||||
- **`not`**, which is not interpreted at all: there is no faithful form for "anything except this".
|
||||
|
||||
|
|
|
|||
|
|
@ -74,6 +74,81 @@ const renderAndSubmit = async (
|
|||
};
|
||||
|
||||
describe("ToolTestModal", () => {
|
||||
it("reports a missing required argument instead of calling the tool (#2123)", async () => {
|
||||
// A union branch's fields render optional — a static form cannot demand
|
||||
// every branch's — so the chosen shape's own requirements are checked here.
|
||||
const callTool = vi.fn();
|
||||
const tool = makeTool({
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "email" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["kind", "address"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "sms" },
|
||||
phone: { type: "string" },
|
||||
},
|
||||
required: ["kind", "phone"],
|
||||
},
|
||||
],
|
||||
},
|
||||
} as unknown as Partial<Tool>);
|
||||
const api = render(
|
||||
<ToolTestModal
|
||||
tool={tool}
|
||||
inspectorClient={fakeClient(callTool)}
|
||||
width={80}
|
||||
height={24}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
setSubmitValue({ __variant: "0", __b0__kind: "email" });
|
||||
api.stdin.write("\r");
|
||||
await tick();
|
||||
await tick();
|
||||
// The assertion is the transition, not the frame — this suite drives state,
|
||||
// not rendered text (see the note at the top of the file).
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
api.unmount();
|
||||
});
|
||||
|
||||
it("names every missing required argument (#2123)", async () => {
|
||||
const callTool = vi.fn();
|
||||
const tool = makeTool({
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
required: ["a", "b"],
|
||||
},
|
||||
} as unknown as Partial<Tool>);
|
||||
const api = render(
|
||||
<ToolTestModal
|
||||
tool={tool}
|
||||
inspectorClient={fakeClient(callTool)}
|
||||
width={80}
|
||||
height={24}
|
||||
onClose={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
await tick();
|
||||
setSubmitValue({});
|
||||
api.stdin.write("\r");
|
||||
await tick();
|
||||
await tick();
|
||||
// Two missing names, which is also the plural branch of the message.
|
||||
expect(callTool).not.toHaveBeenCalled();
|
||||
api.unmount();
|
||||
});
|
||||
|
||||
it("renders the form initially without invoking the client", async () => {
|
||||
const callTool = vi.fn();
|
||||
const api = render(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { describe, it, expect } from "vitest";
|
||||
import { decodeFormValues, schemaToForm } from "../src/utils/schemaToForm.js";
|
||||
import {
|
||||
decodeFormValues,
|
||||
missingRequiredFields,
|
||||
schemaToForm,
|
||||
} from "../src/utils/schemaToForm.js";
|
||||
|
||||
describe("schemaToForm", () => {
|
||||
it("returns an empty Parameters section when there is no schema", () => {
|
||||
|
|
@ -655,6 +659,26 @@ describe("schemaToForm", () => {
|
|||
).toEqual({ n: 7, a: "x" });
|
||||
});
|
||||
|
||||
it("keeps a decoded argument named __proto__", () => {
|
||||
// Assigning it would invoke the legacy prototype setter, so a field
|
||||
// prefixed safely in the form would vanish on the way to the call.
|
||||
const schema = {
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { ["__proto__"]: { type: "string" } },
|
||||
},
|
||||
{ type: "object", properties: { other: { type: "string" } } },
|
||||
] as unknown[],
|
||||
};
|
||||
const decoded = decodeFormValues(schema, {
|
||||
__variant: "0",
|
||||
__b0____proto__: "kept",
|
||||
});
|
||||
expect(Object.hasOwn(decoded, "__proto__")).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps a base argument whose name looks generated", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
|
|
@ -695,4 +719,103 @@ describe("schemaToForm", () => {
|
|||
expect(form.sections).toEqual([{ title: "Parameters", fields: [] }]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edge shapes (#2123)", () => {
|
||||
it("renders a union that declares no root properties", () => {
|
||||
const form = schemaToForm(
|
||||
{
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{ type: "object", properties: { a: { type: "string" } } },
|
||||
{ type: "object", properties: { b: { type: "string" } } },
|
||||
],
|
||||
},
|
||||
"no_base",
|
||||
);
|
||||
expect(form.sections[0]!.fields.map((f) => f.name)).toEqual([
|
||||
"__variant",
|
||||
]);
|
||||
expect(form.sections[1]!.fields.map((f) => f.name)).toEqual(["__b0__a"]);
|
||||
});
|
||||
|
||||
it("returns the values unchanged for a schema with no properties", () => {
|
||||
const values = { anything: "x" };
|
||||
expect(decodeFormValues({ type: "object" }, values)).toBe(values);
|
||||
});
|
||||
|
||||
it("ignores a malformed property declaration when restoring constants", () => {
|
||||
// `properties` values are `unknown`; a `null` entry must not throw on the
|
||||
// way out of the form any more than it does on the way in.
|
||||
const schema = {
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { broken: null, kind: { const: "a" } },
|
||||
},
|
||||
{ type: "object", properties: { kind: { const: "b" } } },
|
||||
] as unknown[],
|
||||
};
|
||||
expect(
|
||||
decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }),
|
||||
).toEqual({ kind: "a" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("missingRequiredFields (#2123)", () => {
|
||||
const REQUIRED_UNION = {
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "email" },
|
||||
address: { type: "string" },
|
||||
},
|
||||
required: ["kind", "address"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: {
|
||||
kind: { type: "string", const: "sms" },
|
||||
phone: { type: "string" },
|
||||
},
|
||||
required: ["kind", "phone"],
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
it("reports what the chosen branch requires and the values omit", () => {
|
||||
// A branch's fields render optional — a static form cannot demand every
|
||||
// branch's — so the requirement is checked against the chosen shape here
|
||||
// rather than sending a call known to violate the schema.
|
||||
expect(
|
||||
missingRequiredFields(
|
||||
REQUIRED_UNION,
|
||||
{ kind: "email" },
|
||||
{ __variant: "0" },
|
||||
),
|
||||
).toEqual(["address"]);
|
||||
});
|
||||
|
||||
it("reports nothing once the chosen branch is satisfied", () => {
|
||||
expect(
|
||||
missingRequiredFields(
|
||||
REQUIRED_UNION,
|
||||
{ kind: "sms", phone: "555" },
|
||||
{ __variant: "1" },
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
it("checks the root's own required fields when there is no union", () => {
|
||||
const schema = {
|
||||
type: "object",
|
||||
properties: { message: { type: "string" } },
|
||||
required: ["message"],
|
||||
};
|
||||
expect(missingRequiredFields(schema, {})).toEqual(["message"]);
|
||||
expect(missingRequiredFields(schema, { message: "hi" })).toEqual([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -5,7 +5,11 @@ import { InspectorClient } from "@inspector/core/mcp/index.js";
|
|||
import { AuthRecoveryRequiredError } from "@inspector/core/auth/challenge.js";
|
||||
import type { Tool, CallToolResult } from "@modelcontextprotocol/client";
|
||||
import type { JsonValue } from "@inspector/core/mcp/index.js";
|
||||
import { decodeFormValues, schemaToForm } from "../utils/schemaToForm.js";
|
||||
import {
|
||||
decodeFormValues,
|
||||
missingRequiredFields,
|
||||
schemaToForm,
|
||||
} from "../utils/schemaToForm.js";
|
||||
import { ScrollView, type ScrollViewRef } from "ink-scroll-view";
|
||||
|
||||
interface ToolTestModalProps {
|
||||
|
|
@ -123,6 +127,23 @@ export function ToolTestModal({
|
|||
// the base fields plus the chosen branch's, and nothing from the others.
|
||||
const values = decodeFormValues(tool.inputSchema, rawValues);
|
||||
|
||||
// A branch's fields are rendered optional — only one alternative applies to
|
||||
// a call, and requiring every branch's would deadlock a static form — so
|
||||
// the chosen shape's own requirements are checked here instead. Reported
|
||||
// rather than sent: a call known to violate the schema teaches the user
|
||||
// nothing about the server (#2123).
|
||||
const missing = missingRequiredFields(tool.inputSchema, values, rawValues);
|
||||
if (missing.length > 0) {
|
||||
setResult({
|
||||
input: values,
|
||||
output: null,
|
||||
error: `Missing required argument${missing.length > 1 ? "s" : ""}: ${missing.join(", ")}`,
|
||||
duration: 0,
|
||||
});
|
||||
setState("results");
|
||||
return;
|
||||
}
|
||||
|
||||
setState("loading");
|
||||
const startTime = Date.now();
|
||||
|
||||
|
|
|
|||
|
|
@ -138,7 +138,7 @@ function branchFields(
|
|||
branches: { declaredFields: string[] }[],
|
||||
index: number,
|
||||
): string[] {
|
||||
const own = branches[index]?.declaredFields ?? [];
|
||||
const own = branches[index]!.declaredFields;
|
||||
return [...new Set([...own, ...sharedFieldNames(base, branches)])];
|
||||
}
|
||||
|
||||
|
|
@ -256,11 +256,7 @@ export function decodeFormValues<T>(
|
|||
}
|
||||
|
||||
const { variant, prefix } = generatedNames(base, branches);
|
||||
const selected = Number(values[variant]);
|
||||
const branchIndex =
|
||||
Number.isInteger(selected) && selected >= 0 && selected < branches.length
|
||||
? selected
|
||||
: 0;
|
||||
const branchIndex = selectedBranchIndex(base, branches, values);
|
||||
const branch = branches[branchIndex]!;
|
||||
|
||||
const generated = new Set<string>([variant]);
|
||||
|
|
@ -270,21 +266,42 @@ export function decodeFormValues<T>(
|
|||
}
|
||||
});
|
||||
|
||||
const decoded: Record<string, T> = {};
|
||||
for (const [name, value] of Object.entries(values)) {
|
||||
if (!generated.has(name)) {
|
||||
decoded[name] = value;
|
||||
}
|
||||
}
|
||||
for (const name of branchFields(base, branches, branchIndex)) {
|
||||
const value = values[branchFieldName(prefix, branchIndex, name)];
|
||||
if (value !== undefined) {
|
||||
decoded[name] = value;
|
||||
}
|
||||
}
|
||||
// Built through `fromEntries` rather than by assignment: `__proto__` is a
|
||||
// legal argument name, and assigning it would invoke the legacy prototype
|
||||
// setter — the field would be prefixed safely in the form and then vanish on
|
||||
// the way to the call.
|
||||
const decoded: Record<string, T> = Object.fromEntries([
|
||||
...Object.entries(values).filter(([name]) => !generated.has(name)),
|
||||
...branchFields(base, branches, branchIndex)
|
||||
.map(
|
||||
(name) =>
|
||||
[name, values[branchFieldName(prefix, branchIndex, name)]] as const,
|
||||
)
|
||||
.filter(([, value]) => value !== undefined),
|
||||
]);
|
||||
/* v8 ignore next -- an offerable branch always carries properties */
|
||||
return applyConstants(branch.schema.properties ?? {}, decoded);
|
||||
}
|
||||
|
||||
/**
|
||||
* Which branch the variant select names, clamped to one that exists — a form
|
||||
* value is whatever the user's terminal produced, and the fallback is the first
|
||||
* branch, which is what the select opens on.
|
||||
*/
|
||||
function selectedBranchIndex(
|
||||
base: { properties?: Record<string, unknown> },
|
||||
branches: { declaredFields: string[] }[],
|
||||
values: Record<string, unknown>,
|
||||
): number {
|
||||
const { variant } = generatedNames(base, branches);
|
||||
const selected = Number(values[variant]);
|
||||
return Number.isInteger(selected) &&
|
||||
selected >= 0 &&
|
||||
selected < branches.length
|
||||
? selected
|
||||
: 0;
|
||||
}
|
||||
|
||||
/** Overwrite every `const`-pinned field with the value its schema fixes. */
|
||||
function applyConstants<T>(
|
||||
properties: Record<string, unknown>,
|
||||
|
|
@ -294,11 +311,10 @@ function applyConstants<T>(
|
|||
([, schema]) => constOf(schema) !== undefined,
|
||||
);
|
||||
if (pinned.length === 0) return values;
|
||||
const result = { ...values };
|
||||
for (const [name, schema] of pinned) {
|
||||
result[name] = constOf(schema) as T;
|
||||
}
|
||||
return result;
|
||||
return Object.fromEntries([
|
||||
...Object.entries(values),
|
||||
...pinned.map(([name, schema]) => [name, constOf(schema) as T]),
|
||||
]);
|
||||
}
|
||||
|
||||
/** Build the ink-form fields for one already-flattened object schema. */
|
||||
|
|
@ -365,6 +381,7 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
|
|||
field = {
|
||||
type: "select",
|
||||
...baseField,
|
||||
/* v8 ignore next -- guarded by `isStringEnum(property.enum)` above */
|
||||
options: toSelectOptions(property.enum ?? [], property.enumNames),
|
||||
} as FormField;
|
||||
} else {
|
||||
|
|
@ -420,3 +437,32 @@ function buildFields(schema: JsonSchemaObject): FormField[] {
|
|||
|
||||
return fields;
|
||||
}
|
||||
|
||||
/**
|
||||
* The required fields the chosen shape does not supply — what a static form
|
||||
* cannot enforce for itself.
|
||||
*
|
||||
* A branch's fields are rendered optional because only one alternative applies
|
||||
* to a call and ink-form would otherwise demand every branch's, deadlocking the
|
||||
* form. That makes the *form* satisfiable, not the call: selecting `email` and
|
||||
* leaving `address` empty still violates the schema. So the requirement is
|
||||
* checked at submit instead, against the branch the variant select names, and
|
||||
* the caller reports it rather than sending a call known to be invalid.
|
||||
*
|
||||
* Takes the **decoded** values — what would actually be sent.
|
||||
*/
|
||||
export function missingRequiredFields(
|
||||
schema: JsonSchemaObject | null | undefined,
|
||||
decoded: Record<string, unknown>,
|
||||
rawValues: Record<string, unknown> = {},
|
||||
): string[] {
|
||||
const { base, branches } = resolveRootUnion(schema ?? {});
|
||||
const effective =
|
||||
branches.length === 0
|
||||
? base
|
||||
: branches[selectedBranchIndex(base, branches, rawValues)]!.schema;
|
||||
return (effective.required ?? []).filter((name) => {
|
||||
const value = decoded[name];
|
||||
return value === undefined || value === "";
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -739,6 +739,12 @@ describe("admitsNull", () => {
|
|||
expect(admitsNull({ type: ["string", "null"], const: null })).toBe(true);
|
||||
});
|
||||
|
||||
it("honors a sibling `nullable` flag", () => {
|
||||
expect(admitsNull({ type: "string", nullable: true, const: null })).toBe(
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not override a sibling that rejects null", () => {
|
||||
// `const` is conjunctive with its siblings, not an override: both of
|
||||
// these reject every value, so claiming nullability would let the
|
||||
|
|
|
|||
|
|
@ -327,6 +327,16 @@ describe("resolveRootUnion", () => {
|
|||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("treats an empty additionalProperties schema as permissive", () => {
|
||||
// `{}` is the JSON Schema equivalent of `true` — it constrains nothing.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
additionalProperties: {},
|
||||
anyOf: [EMAIL, SMS],
|
||||
});
|
||||
expect(branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("allows a restrictive additionalProperties the branches stay within", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
|
|
@ -435,6 +445,66 @@ describe("resolveRootUnion", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("oneOf exclusivity", () => {
|
||||
it("offers a discriminated oneOf", () => {
|
||||
expect(
|
||||
resolveRootUnion({ type: "object", oneOf: [EMAIL, SMS] }).branches,
|
||||
).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declines a oneOf whose alternatives are not mutually exclusive", () => {
|
||||
// `oneOf` demands that EXACTLY one alternative match; flattening offers
|
||||
// them as if any would do. Here entering `a` satisfies both, so a call
|
||||
// the form calls valid is one the server refuses.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
oneOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
|
||||
it("still offers the same alternatives under anyOf", () => {
|
||||
// `anyOf` makes no exclusivity claim, so overlapping alternatives are
|
||||
// exactly what it means.
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { a: { type: "string" }, b: { type: "string" } },
|
||||
required: ["a"],
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(branches).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("declines a oneOf whose named discriminator does not distinguish", () => {
|
||||
const { branches } = resolveRootUnion({
|
||||
type: "object",
|
||||
discriminator: { propertyName: "kind" },
|
||||
oneOf: [EMAIL, { ...EMAIL, properties: { ...EMAIL.properties } }],
|
||||
});
|
||||
expect(branches).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unions it declines to offer", () => {
|
||||
// Each of these would produce a picker with an option that renders
|
||||
// nothing, which is the failure this module exists to prevent.
|
||||
|
|
@ -572,6 +642,29 @@ describe("resolveRootUnion", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("falls back to the branch whose required fields the values supply", () => {
|
||||
// An undiscriminated union still has shapes, and the required-field gate
|
||||
// accepts any satisfied branch — so the picker must open on the one the
|
||||
// values satisfy rather than showing a shape they do not describe.
|
||||
const undiscriminated = resolveRootUnion({
|
||||
type: "object",
|
||||
anyOf: [
|
||||
{
|
||||
type: "object",
|
||||
properties: { address: { type: "string" } },
|
||||
required: ["address"],
|
||||
},
|
||||
{
|
||||
type: "object",
|
||||
properties: { phone: { type: "string" } },
|
||||
required: ["phone"],
|
||||
},
|
||||
],
|
||||
}).branches;
|
||||
expect(selectBranchIndex(undiscriminated, { phone: "555" })).toBe(1);
|
||||
expect(selectBranchIndex(undiscriminated, {})).toBeNull();
|
||||
});
|
||||
|
||||
it("reports none when the values identify nothing", () => {
|
||||
expect(selectBranchIndex(branches, {})).toBeNull();
|
||||
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue