fix: address Copilot review round 3 — conjunctive keywords, unflattenable allOf, branch inference

- rootUnion: a branch restating a constraint the root states differently is
  declined (keywords at one level are conjunctive and no intersection is
  computed here), while annotations may disagree freely. An `allOf` with a
  member it cannot fold in — JSON Schema's boolean form, or a $ref whose
  constraints are unknown rather than absent — is left intact instead of
  merged-and-stripped, which would turn an unsatisfiable schema into a
  fillable form.
- rootUnion: `declaresAnyFields` counts the raw composition members, so
  `hasInputFields` reports fields for a union the resolver declines and an
  App tool is never auto-invoked with empty arguments.
- SchemaForm: the picker opens on the branch the supplied values identify
  (an App deep link overlays its args, so they can name another branch), and
  a branch switch no longer carries a value the outgoing branch declared —
  the branches may type the same name differently.
- TUI: a base property some branch redeclares is offered in EVERY branch's
  section under that branch's inherited or specialized declaration, so a
  branch that does not redeclare it can still supply a required root
  argument.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 02:01:44 -04:00
commit 1a271e4931
10 changed files with 442 additions and 69 deletions

View file

@ -206,12 +206,20 @@ v2/main/
│ │ # field; schemaToForm.decodeFormValues translates
│ │ # back on submit), and convertToolParameters (which
│ │ # branch's schema types a CLI --tool-arg).
│ │ # DECLINES rather than half-reads: a union whose
│ │ # members are not ALL field-carrying objects, and a
│ │ # schema carrying BOTH oneOf and anyOf (independent
│ │ # keywords, satisfied together — picking one drops
│ │ # real constraints). Does not interpret `not` at
│ │ # all — #2123;
│ │ # DECLINES rather than half-reads, since keywords at
│ │ # one level are CONJUNCTIVE and it computes no
│ │ # intersections: a union whose members are not ALL
│ │ # field-carrying objects (or whose member `type`
│ │ # rules objects out), a branch restating a
│ │ # constraint the root states differently, an allOf
│ │ # carrying a member it cannot fold in (`false`, a
│ │ # $ref) — dropping the keyword there would turn an
│ │ # UNSATISFIABLE schema into a fillable form — and a
│ │ # schema carrying BOTH oneOf and anyOf. Does not
│ │ # interpret `not` at all. Declining changes what
│ │ # RENDERS, never whether the tool takes arguments:
│ │ # declaresAnyFields counts the raw members, so an
│ │ # App tool is never auto-invoked with `{}` — #2123;
│ │ # schemaLint.ts: tool-schema PORTABILITY lint —
│ │ # constructs that are legal JSON Schema and are
│ │ # refused or mishandled by real MCP clients (a bare

View file

@ -308,7 +308,15 @@ The **CLI** has no form at all, but the same flattening decides how `--tool-arg`
All three read one helper, [`core/json/rootUnion.ts`](./core/json/rootUnion.ts), so they cannot drift on which schemas they can render.
Four things it deliberately does **not** do, each falling back to whatever the root `properties` describe rather than claiming something untrue. A union whose members are not all field-carrying object schemas is left alone rather than offered as a picker with options that render nothing — as is one whose member `type` rules objects out, since tool arguments are a JSON object and such a member can never match. A union whose branch **contradicts** the root about a property's `type` is declined too: the two constraints are conjunctive, so `string` under a root `number` describes a value that cannot exist, and rendering either type 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`.) A schema carrying **both** `oneOf` and `anyOf` is declined outright: they are independent keywords a value satisfies together, not two spellings of one union, so reading one and dropping the other builds a form that silently omits real constraints — and satisfying both honestly means offering the cross product of their alternatives, which no real schema has yet asked for. And `not` is not interpreted at all: there is no faithful form for "anything except this".
What it declines to flatten is as deliberate as what it flattens, and every case falls back to whatever the schema's own `properties` describe rather than claiming something untrue:
- **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.
- **An `allOf` with a member it cannot fold in** — JSON Schema's boolean form (`allOf: [false, …]` admits nothing) or a `$ref`, whose constraints are unknown rather than absent. Merging the rest and dropping the keyword would turn an unsatisfiable schema into a fillable form.
- **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".
Declining changes what *renders*, never whether the tool is treated as taking arguments: a declined union still has fields, so an App tool carrying one still asks for them rather than auto-invoking with `{}`.
#### Unportable tool schemas

View file

@ -536,6 +536,32 @@ describe("schemaToForm", () => {
});
});
it("offers a shared base property in every branch's section", () => {
const schema = {
type: "object",
properties: { count: {} },
required: ["count"],
anyOf: [
{ type: "object", properties: { count: { type: "integer" } } },
{ type: "object", properties: { other: { type: "string" } } },
],
};
const form = schemaToForm(schema, "shared");
// Rendered only in branch A's section, branch B could never supply the
// required root argument — the chosen branch's fields are what decode.
expect(form.sections[2]!.fields.map((field) => field.name)).toEqual([
"__b1__other",
"__b1__count",
]);
expect(
decodeFormValues(schema, {
__variant: "1",
__b1__other: "x",
__b1__count: "4",
}),
).toEqual({ other: "x", count: "4" });
});
it("keeps its generated names clear of the schema's own", () => {
const form = schemaToForm(
{

View file

@ -116,6 +116,32 @@ function branchFieldName(
return `${prefix}${branchIndex}__${name}`;
}
/**
* Base-declared names that at least one branch also declares. They move out of
* the base section and into every branch's, so the branch showing is the one
* whose declaration renders — and so a branch that does not specialize the name
* still offers it rather than losing a root argument it must supply.
*/
function sharedFieldNames(
base: { properties?: Record<string, unknown> },
branches: { declaredFields: string[] }[],
): string[] {
const declared = new Set(branches.flatMap((branch) => branch.declaredFields));
return Object.keys(base.properties ?? {}).filter((name) =>
declared.has(name),
);
}
/** The property names one branch's section renders, under prefixed names. */
function branchFields(
base: { properties?: Record<string, unknown> },
branches: { declaredFields: string[] }[],
index: number,
): string[] {
const own = branches[index]?.declaredFields ?? [];
return [...new Set([...own, ...sharedFieldNames(base, branches)])];
}
/** The `const` a property schema pins its value to, if any. */
function constOf(schema: unknown): unknown {
if (typeof schema !== "object" || schema === null) return undefined;
@ -148,17 +174,18 @@ export function schemaToForm(
}
const { variant, prefix } = generatedNames(base, branches);
const branchDeclared = new Set(
branches.flatMap((branch) => branch.declaredFields),
);
const shared = sharedFieldNames(base, branches);
// The base section renders what the base *alone* declares. A property a
// branch also declares is rendered in that branch's section instead, showing
// the branch's specialization — root `count: {}` under branch
// `count: { type: "number" }` is a number field there, not a string here.
// The base section renders what the base *alone* declares. A property some
// branch also declares moves into every branch's section, so the branch's
// specialization is what renders there — root `count: {}` under branch
// `count: { type: "number" }` is a number field, not a string — while a
// branch that does not specialize it still offers it, under its own inherited
// declaration. Rendering it once in the base section instead would strand it:
// the chosen branch's decoded fields are what reach the call.
const baseProperties = Object.fromEntries(
Object.entries(base.properties ?? {}).filter(
([name]) => !branchDeclared.has(name),
([name]) => !shared.includes(name),
),
);
@ -187,7 +214,7 @@ export function schemaToForm(
// a form that can never be submitted.
branches.forEach((branch, index) => {
const properties = Object.fromEntries(
branch.declaredFields.map((name) => [
branchFields(base, branches, index).map((name) => [
branchFieldName(prefix, index, name),
branch.schema.properties?.[name],
]),
@ -237,8 +264,8 @@ export function decodeFormValues<T>(
const branch = branches[branchIndex]!;
const generated = new Set<string>([variant]);
branches.forEach((each, index) => {
for (const name of each.declaredFields) {
branches.forEach((_each, index) => {
for (const name of branchFields(base, branches, index)) {
generated.add(branchFieldName(prefix, index, name));
}
});
@ -249,7 +276,7 @@ export function decodeFormValues<T>(
decoded[name] = value;
}
}
for (const name of branch.declaredFields) {
for (const name of branchFields(base, branches, branchIndex)) {
const value = values[branchFieldName(prefix, branchIndex, name)];
if (value !== undefined) {
decoded[name] = value;

View file

@ -2100,6 +2100,55 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(mode.value).toBe("fast");
});
it("opens on the branch the supplied values identify", () => {
// A deep link overlays its args on the initial defaults, so values for
// one branch can arrive while the picker would otherwise open on another.
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ kind: "sms", phone: "555" }}
onChange={vi.fn()}
/>,
);
expect(
(screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement)
.value,
).toBe("sms");
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
});
it("does not carry a value the outgoing branch declared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
title: "A",
properties: { value: { type: "number", title: "Value" } },
},
{
type: "object",
title: "B",
properties: { value: { type: "boolean", title: "Value" } },
},
],
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ value: 3 }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "B" }));
// Branch B types `value` as a boolean; carrying the 3 would check the box
// and submit a number the branch rejects.
expect(onChange).toHaveBeenCalledWith({});
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -571,6 +571,36 @@ function resolveValue(
return getDefaultValue(fieldSchema);
}
/**
* The branch a set of values already identifies, or `null` when they identify
* none uniquely.
*
* A discriminated union pins its discriminator with `const`, so values carrying
* one name the branch they belong to. This matters because the form is not
* always mounted empty: an App deep link overlays `appArgs` on the initial
* defaults, so `{ kind: "sms", … }` can arrive while the picker would otherwise
* open on the first branch — showing one shape's controls while a differently
* shaped set of values sits underneath, ready to be submitted (#2123).
*/
function matchBranchIndex(
branches: { schema: InspectorFormSchema }[],
values: Record<string, unknown>,
): number | null {
const matches: number[] = [];
branches.forEach((branch, index) => {
const pinned = Object.entries(branch.schema.properties ?? {}).filter(
([, fieldSchema]) => fieldSchema.const !== undefined,
);
if (
pinned.length > 0 &&
pinned.every(([name, fieldSchema]) => values[name] === fieldSchema.const)
) {
matches.push(index);
}
});
return matches.length === 1 ? matches[0] : null;
}
export function SchemaForm({
schema,
values,
@ -588,7 +618,9 @@ export function SchemaForm({
// Which alternative the form is currently showing. Held here because it is a
// property of this rendering, not of the arguments: `values` carries what the
// user typed, and nothing in it names a branch.
const [branchIndex, setBranchIndex] = useState(0);
const [branchIndex, setBranchIndex] = useState(
() => matchBranchIndex(branches, values) ?? 0,
);
// A form reused for another entity can be handed a shorter union, so the
// index is clamped rather than trusted — `resetKey` resets it below, but a
// caller that omits it (the elicitation panels mount fresh) supplies none.
@ -622,8 +654,10 @@ export function SchemaForm({
useValueChange(resetKey, () => {
setEnlargedFields(new Set());
// Which branch is selected belongs to the entity it was chosen for, for the
// same reason enlargement does.
setBranchIndex(0);
// same reason enlargement does — reset to whichever branch the new values
// identify, so the visible selection cannot disagree with what would be
// submitted, and to the first when they identify none.
setBranchIndex(matchBranchIndex(branches, values) ?? 0);
});
// Stable so a field's reporting effect subscribes once, not per render. The
@ -687,13 +721,21 @@ export function SchemaForm({
if (!nextBranch) return;
setBranchIndex(nextIndex);
const nextProperties = nextBranch.schema.properties ?? {};
// Only what the *base* contributed is carried across — a value the outgoing
// branch declared belongs to that branch's shape, and a name the two
// branches type differently would arrive as the wrong type entirely (a `3`
// typed into branch A's number field landing in branch B's checkbox). A
// field the incoming branch pins to a `const` is likewise not carried: the
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value.
const outgoing = new Set(activeBranch?.declaredFields ?? []);
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
// A field the incoming branch pins to a `const` is not carried: the two
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value, so keeping what the outgoing branch put
// there would leave the arguments claiming the shape they no longer have.
if (values[name] !== undefined && fieldSchema.const === undefined) {
if (
values[name] !== undefined &&
fieldSchema.const === undefined &&
!outgoing.has(name)
) {
carried[name] = values[name];
}
}

View file

@ -1,5 +1,8 @@
import { describe, it, expect } from "vitest";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
import {
declaresAnyFields,
resolveRootUnion,
} from "@inspector/core/json/rootUnion.js";
const EMAIL = {
type: "object",
@ -76,6 +79,43 @@ describe("resolveRootUnion", () => {
expect(Object.keys(base.properties ?? {})).toEqual(["note"]);
});
it("leaves an unsatisfiable allOf alone rather than dropping it", () => {
// `allOf: [false, …]` admits nothing. Treating the boolean member as a
// no-op and stripping the keyword would render a fillable form for a schema
// that can never be satisfied.
const schema = {
type: "object" as const,
allOf: [
false as unknown,
{ type: "object", properties: { x: { type: "string" } } },
],
};
const { base, branches } = resolveRootUnion(schema);
expect(branches).toEqual([]);
expect(base.properties).toBeUndefined();
expect(base.allOf).toBe(schema.allOf);
});
it("leaves an allOf carrying a $ref alone", () => {
// The referent is not resolved here, so its constraints are unknown rather
// than absent.
const { base } = resolveRootUnion({
type: "object",
allOf: [{ $ref: "#/$defs/Thing" }],
});
expect(base.allOf).toHaveLength(1);
expect(base.properties).toBeUndefined();
});
it("declines a union when the allOf beneath it could not be flattened", () => {
const { branches } = resolveRootUnion({
type: "object",
allOf: [{ $ref: "#/$defs/Thing" }],
anyOf: [EMAIL, SMS],
});
expect(branches).toEqual([]);
});
it("merges allOf branches unconditionally", () => {
const { base, branches } = resolveRootUnion({
type: "object",
@ -128,6 +168,40 @@ describe("resolveRootUnion", () => {
expect(branches[0].declaredFields).toEqual(["id"]);
});
it("declines a union whose branch restates a constraint differently", () => {
// Both apply, so root `minimum: 10` under branch `minimum: 0` is still 10.
// Rendering either side would accept a value the schema rejects.
const { branches } = resolveRootUnion({
type: "object",
properties: { x: { type: "number", minimum: 10 } },
anyOf: [
{ type: "object", properties: { x: { minimum: 0 } } },
{ type: "object", properties: { other: { type: "string" } } },
],
});
expect(branches).toEqual([]);
});
it("tolerates a branch disagreeing only about annotations", () => {
const { branches } = resolveRootUnion({
type: "object",
properties: { x: { type: "number", description: "root" } },
anyOf: [
{
type: "object",
properties: { x: { description: "branch", maximum: 3 } },
},
{ type: "object", properties: { other: { type: "string" } } },
],
});
expect(branches).toHaveLength(2);
expect(branches[0].schema.properties?.x).toEqual({
type: "number",
description: "branch",
maximum: 3,
});
});
it("declines a union whose branch contradicts the root's type for a field", () => {
// `string` under a base `number` describes a value that cannot exist, so
// flattening it would render one type and accept what the schema rejects.
@ -280,4 +354,31 @@ describe("resolveRootUnion", () => {
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
});
});
describe("declaresAnyFields", () => {
it("sees fields on a union the resolver declines", () => {
// A declined union still HAS fields — reporting none would auto-invoke an
// App tool with `{}` instead of asking for them.
const schema = {
type: "object" as const,
anyOf: [EMAIL, { $ref: "#/$defs/SMS" }],
};
expect(resolveRootUnion(schema).branches).toEqual([]);
expect(declaresAnyFields(schema)).toBe(true);
});
it("sees fields nested a level down", () => {
expect(
declaresAnyFields({
type: "object",
allOf: [{ type: "object", anyOf: [EMAIL, SMS] }],
}),
).toBe(true);
});
it("reports none for a bare object schema", () => {
expect(declaresAnyFields({ type: "object" })).toBe(false);
expect(declaresAnyFields(undefined)).toBe(false);
});
});
});

View file

@ -118,6 +118,22 @@ describe("hasInputFields with root composition (#2123)", () => {
).toBe(true);
});
it("sees fields on a union the form declines to flatten", () => {
// Otherwise an App tool with such a schema is treated as input-free and
// invoked immediately with `{}`.
expect(
hasInputFields(
tool({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ $ref: "#/$defs/Other" },
],
}),
),
).toBe(true);
});
it("still reports no fields for a bare object schema", () => {
expect(hasInputFields(tool({ type: "object" }))).toBe(false);
});

View file

@ -1,5 +1,5 @@
import type { Tool } from "@modelcontextprotocol/client";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
import { declaresAnyFields } from "@inspector/core/json/rootUnion.js";
/**
* Returns the display label for an MCP entity that follows the BaseMetadata
@ -17,19 +17,16 @@ export function resolveDisplayLabel(name: string, title?: string): string {
* one place so the definition of "has fields" stays consistent if it ever
* grows to consider `additionalProperties` etc.
*
* Root composition is resolved first, since a schema declaring its fields on a
* root `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with
* such a schema would otherwise launch with empty arguments rather than asking
* for them.
* Root composition counts, since a schema declaring its fields on a root
* `allOf`/`oneOf`/`anyOf` has none of its own (#2123) — an App tool with such a
* schema would otherwise launch with empty arguments rather than asking for
* them. Counted from the composition members directly rather than from a
* resolved union, so a schema whose composition the form declines to flatten
* still reports the fields it has: it renders fewer controls, not none, and
* auto-invoking it would be wrong either way.
*/
export function hasInputFields(tool: Tool): boolean {
const { base, branches } = resolveRootUnion(tool.inputSchema);
return (
Object.keys(base.properties ?? {}).length > 0 ||
branches.some(
(branch) => Object.keys(branch.schema.properties ?? {}).length > 0,
)
);
return declaresAnyFields(tool.inputSchema);
}
/**

View file

@ -42,6 +42,11 @@ export interface RootUnionSchema {
* own repro does). Read only to *label* a branch — never to validate.
*/
discriminator?: { propertyName?: string };
/**
* Read only to *decline* a member: its referent is not resolved here, so a
* `$ref` member's constraints are unknown rather than absent.
*/
$ref?: string;
}
/**
@ -143,16 +148,59 @@ function isOfferable(branch: RootUnionSchema): boolean {
}
/**
* Merge two declarations of the same property name.
* Keywords that annotate rather than constrain. Two declarations may disagree
* about these without describing different values, so a disagreement here is
* not a reason to refuse to flatten.
*/
const ANNOTATION_KEYWORDS = new Set([
"title",
"description",
"examples",
"deprecated",
"readOnly",
"writeOnly",
"$comment",
]);
/** Structural equality, via canonical JSON — enough for schema keyword values. */
function sameValue(a: unknown, b: unknown): boolean {
return a === b || JSON.stringify(a) === JSON.stringify(b);
}
/**
* Whether two declarations of one property name disagree about a constraint.
*
* Where a base and a branch both name a property, JSON Schema applies **both**
* — so keeping only the branch's would silently drop the base's constraints
* (root `{ minimum: 0 }` plus branch `{ maximum: 10 }` must keep the floor).
* A shallow union preserves every keyword only one side states, with the
* branch winning a keyword both state, being the more specific declaration.
*
* A *contradiction* is not resolvable this way and is not resolved here — see
* {@link contradicts}, which declines such a union outright.
* — the value must satisfy the two together. A keyword only one side states is
* therefore safe to carry across, but a keyword they state *differently* is a
* conjunction this module cannot compute: root `minimum: 10` under branch
* `minimum: 0` is still 10, disjoint `enum`s leave nothing satisfiable at all,
* and `type: "string"` under `type: "number"` describes a value that cannot
* exist. Taking either side would render a form that accepts what the schema
* rejects, so the caller declines the composition instead.
*/
function conflicts(baseProperty: unknown, branchProperty: unknown): boolean {
const a = toBranch(baseProperty);
const b = toBranch(branchProperty);
if (a === null || b === null) {
return (
baseProperty !== undefined && !sameValue(baseProperty, branchProperty)
);
}
const left = a as Record<string, unknown>;
const right = b as Record<string, unknown>;
return Object.keys(right).some(
(keyword) =>
!ANNOTATION_KEYWORDS.has(keyword) &&
keyword in left &&
!sameValue(left[keyword], right[keyword]),
);
}
/**
* Merge two declarations of the same property name — a shallow union, which is
* the whole conjunction once {@link conflicts} has ruled out a keyword the two
* state differently.
*/
function mergeProperty(
baseProperty: unknown,
@ -167,25 +215,57 @@ function mergeProperty(
}
/**
* Whether a branch states a property `type` its base declaration rules out.
* Whether a member can be folded into a base schema at all.
*
* The two are conjunctive, so `string` under a base `number` describes a value
* that cannot exist. No form can express that, and flattening it would render
* one of the two types and accept values the schema rejects — so the caller
* declines the union instead of picking a side.
* Two ways it cannot, both of which would have the composition keyword
* *removed* while its constraint went unapplied — a form that submits fields
* the schema forbids:
*
* - **It is not an object schema.** JSON Schema's boolean form is legal, and
* `allOf: [false, …]` is unsatisfiable, so silently treating a non-object
* member as a no-op turns "nothing is valid here" into a fillable form.
* - **It is a `$ref`.** The referent is not resolved by this module, so its
* constraints are unknown rather than absent.
*/
function contradicts(base: RootUnionSchema, branch: RootUnionSchema): boolean {
function isFlattenable(member: unknown): boolean {
const branch = toBranch(member);
return branch !== null && branch.$ref === undefined;
}
/** Whether any property declaration of `branch` conflicts with the base's. */
function conflictsWithBase(
base: RootUnionSchema,
branch: RootUnionSchema,
): boolean {
const baseProperties = propertiesOf(base) ?? {};
const branchProperties = propertiesOf(branch) ?? {};
return Object.entries(branchProperties).some(([name, branchProperty]) => {
const a = toBranch(baseProperties[name]);
const b = toBranch(branchProperty);
if (a === null || b === null) return false;
return (
typeof a.type === "string" &&
typeof b.type === "string" &&
a.type !== b.type
);
return Object.entries(branchProperties).some(
([name, branchProperty]) =>
name in baseProperties && conflicts(baseProperties[name], branchProperty),
);
}
/**
* Every property name the schema's composition members declare, whether or not
* the composition could be flattened.
*
* A caller deciding whether a tool takes arguments at all must count these: a
* union this module declines still *has* fields, and reporting "no fields"
* would auto-invoke the tool with `{}` rather than asking for them.
*/
export function declaresAnyFields(
schema: RootUnionSchema | undefined,
): boolean {
if (schema === undefined) return false;
if (Object.keys(propertiesOf(schema) ?? {}).length > 0) return true;
const members = [
...(schema.allOf ?? []),
...(schema.anyOf ?? []),
...(schema.oneOf ?? []),
];
return members.some((member) => {
const branch = toBranch(member);
return branch !== null && declaresAnyFields(branch);
});
}
@ -299,11 +379,28 @@ function branchLabel(
export function resolveRootUnion<T extends RootUnionSchema>(
schema: T,
): ResolvedRootUnion<T> {
const merged = (schema.allOf ?? []).reduce<ResolvedSchema<T>>(
(acc, member) => {
const branch = toBranch(member);
return branch === null ? acc : mergeBranch(acc, branch);
},
// `allOf` is only folded in when EVERY member can be. A member this module
// cannot flatten would otherwise have its keyword stripped by
// `withoutComposition` while its constraint went unapplied — so an
// unsatisfiable `allOf: [false, …]` would render as a fillable form, and a
// `$ref` member's constraints would read as absent rather than unknown. When
// one cannot, nothing is flattened: the schema's own `properties` render, its
// composition keywords stay on it, and no union is offered either, since a
// branch would otherwise be merged against a base whose constraints are not
// all known.
const allOfMembers = schema.allOf ?? [];
const flattenable =
allOfMembers.every(isFlattenable) &&
allOfMembers.every(
(member) =>
!conflictsWithBase(schema, toBranch(member) as RootUnionSchema),
);
if (!flattenable) {
return { base: schema as ResolvedSchema<T>, branches: [] };
}
const merged = allOfMembers.reduce<ResolvedSchema<T>>(
(acc, member) => mergeBranch(acc, toBranch(member) as RootUnionSchema),
schema as ResolvedSchema<T>,
);
const base = withoutComposition(merged);
@ -317,7 +414,9 @@ export function resolveRootUnion<T extends RootUnionSchema>(
branches.length === 0 ||
branches.some(
(branch) =>
branch === null || !isOfferable(branch) || contradicts(base, branch),
branch === null ||
!isOfferable(branch) ||
conflictsWithBase(base, branch),
)
) {
return { base, branches: [] };