/** * Flattening of the JSON Schema **composition** keywords that a tool's * `inputSchema` may carry at its **root** — `allOf`, and a top-level `anyOf` / * `oneOf` union — into the plain object schema a form renderer can build fields * from. * * Both form builders — the web client's `SchemaForm` and the TUI's * `schemaToForm` — enumerate the root schema's `properties` and nothing else. * A schema that keeps its fields on composition branches therefore rendered * **no controls at all**: not a branch picker, not even the raw-JSON fallback a * union-typed *property* already gets, so the tool could only ever be called * with empty arguments (#2123). The 2026-07-28 revision makes such a schema * explicitly legal — `type: "object"` is required at the root, and "any JSON * Schema 2020-12 keyword may appear alongside `type`, including composition * keywords". * * Shared for the same reason {@link ./nullableUnion.ts} is: the two clients * would otherwise grow separate answers to "which schemas can I render", and * the CLI's argument coercion (`convertToolParameters`) needs the same view of * where a property's schema lives. * * Deliberately **not** a JSON Schema evaluator. It rewrites nothing it cannot * do faithfully: `not`, and branches that are not object schemas, are left * alone rather than guessed at (see {@link resolveRootUnion}). */ /** * The keywords this module reads. Both clients' own schema types are * structurally assignable to this — the shape is the minimum needed to * *recognize* root composition, not a full JSON Schema model. */ export interface RootUnionSchema { type?: string | string[]; title?: string; properties?: Record; required?: string[]; allOf?: readonly unknown[]; anyOf?: readonly unknown[]; oneOf?: readonly unknown[]; /** * OpenAPI's discriminator, which real servers emit beside `oneOf` (the issue's * 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; } /** * What a resolved schema is: the caller's own type, intersected with the * keywords this module may have *added* to it. * * The intersection is load-bearing rather than decorative. A schema that keeps * every field on a branch declares no `properties` of its own, so returning the * caller's `T` unchanged would hand back a type on which `properties` does not * exist — precisely the property the flattening was performed to produce. */ export type ResolvedSchema = T & RootUnionSchema; /** One selectable alternative of a root union. */ export interface RootUnionBranch { /** * The base schema merged with this branch: the object schema a renderer * builds fields from while this alternative is selected. */ schema: ResolvedSchema; /** Human-readable name for the picker — see {@link branchLabel}. */ label: string; /** * The names **this branch itself declares** — including one the base also * declares, since a branch commonly *specializes* a root property (root * `count: {}`, branch `count: { type: "number" }`) and a renderer that showed * only the base's version would render the untyped one. */ declaredFields: string[]; } /** What {@link resolveRootUnion} decomposes a root schema into. */ export interface ResolvedRootUnion { /** * The schema without its composition keywords: the root's own `properties` / * `required` with every `allOf` branch merged in. Renderable on its own, and * what a renderer uses when there is no union. */ base: ResolvedSchema; /** * One entry per union alternative, or **empty** when the root carries no * usable union — which is the overwhelmingly common case, so callers can * treat a non-empty array as "this schema needs a branch picker". */ branches: RootUnionBranch[]; } /** Narrow a composition member to a readable object, or `null` if it isn't one. */ function toBranch(value: unknown): RootUnionSchema | null { if (typeof value !== "object" || value === null || Array.isArray(value)) { return null; } return value as RootUnionSchema; } /** Whether a schema's `type` permits an object instance. */ function admitsObject(schema: RootUnionSchema): boolean { const { type } = schema; if (type === undefined) return true; return Array.isArray(type) ? type.includes("object") : type === "object"; } /** A readable `properties` map, or `null` when the value is not one. */ function propertiesOf(schema: RootUnionSchema): Record | null { const { properties } = schema; if ( typeof properties !== "object" || properties === null || Array.isArray(properties) ) { return null; } return properties; } /** * 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 * cannot be filled in: * * - **It carries no fields.** A `{ type: "null" }` member — the nullable * encoding {@link ./nullableUnion.ts} owns — and a `$ref`-only or empty * branch render nothing. * - **`properties` is not an object.** Members arrive as `unknown`, so a * malformed `properties: null` is reachable and would throw in `Object.keys` * rather than being declined. * - **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. */ function isOfferable(branch: RootUnionSchema): boolean { const properties = propertiesOf(branch); return ( properties !== null && Object.keys(properties).length > 0 && admitsObject(branch) ); } /** * 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** * — 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; const right = b as Record; 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, branchProperty: unknown, ): unknown { const a = toBranch(baseProperty); const b = toBranch(branchProperty); if (a === null || b === null) { return branchProperty; } return { ...a, ...b }; } /** * Whether a member can be folded into a base schema at all. * * 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 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]) => 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); }); } /** * Merge a composition branch's `properties` and `required` into a base schema. * * Both keywords are **conjunctive** where they meet: a value satisfying an * `allOf` branch satisfies the base *and* the branch, and a value matching a * union branch must satisfy the root's own constraints too. So properties union * — a name both declare merged through {@link mergeProperty} rather than * replaced — and `required` unions. */ function mergeBranch( base: T, branch: RootUnionSchema, ): ResolvedSchema { const baseProperties = propertiesOf(base) ?? {}; const branchProperties = propertiesOf(branch) ?? {}; const properties: Record = { ...baseProperties }; for (const [name, branchProperty] of Object.entries(branchProperties)) { properties[name] = name in baseProperties ? mergeProperty(baseProperties[name], branchProperty) : branchProperty; } const required = [ ...(base.required ?? []), ...(branch.required ?? []).filter( (name) => !(base.required ?? []).includes(name), ), ]; // One cast, owned here: a branch member is `unknown` on the wire, so its // property schemas are whatever the server sent however `T` declares them — // exactly the situation `normalizeNullableUnion`'s hoist documents. Every // consumer reads a property schema defensively, and the merged object is // otherwise structurally `T`. return { ...base, ...(Object.keys(properties).length > 0 ? { properties } : {}), ...(required.length > 0 ? { required } : {}), } as ResolvedSchema; } /** Strip the composition keywords a resolved schema has absorbed. */ function withoutComposition( schema: T, ): ResolvedSchema { const { allOf: _allOf, anyOf: _anyOf, oneOf: _oneOf, ...rest } = schema; return rest as ResolvedSchema; } /** * A branch's display name, in decreasing order of how deliberate it is: * * 1. the branch's own `title` — the author naming it outright; * 2. the `const` of the discriminator property, when the root names one; * 3. the `const` of the branch's only constant-valued property, which is what a * discriminated union looks like without an OpenAPI `discriminator` (the * `kind: { const: "email" }` shape) — restricted to a *single* candidate, so * a branch with several constants is not labelled by an arbitrary one; * 4. a positional fallback. */ function branchLabel( branch: RootUnionSchema, index: number, discriminatorProperty: string | undefined, ): string { if (typeof branch.title === "string" && branch.title.trim() !== "") { return branch.title; } const properties = propertiesOf(branch) ?? {}; const constOf = (name: string): string | null => { const property = toBranch(properties[name]) as { const?: unknown } | null; const value = property?.const; return typeof value === "string" || typeof value === "number" ? String(value) : null; }; if (discriminatorProperty !== undefined) { const value = constOf(discriminatorProperty); if (value !== null) return value; } const constants = Object.keys(properties) .map((name) => constOf(name)) .filter((value): value is string => value !== null); if (constants.length === 1) return constants[0]; return `Option ${index + 1}`; } /** * Decompose a root schema into the object schema a form renders and, when the * root is a union, the alternatives a picker offers. * * `allOf` is merged unconditionally — its branches are conjunctive, so there is * one correct rendering and no choice to present. A `oneOf` / `anyOf` becomes * `branches` only when **every** member is an object schema carrying fields: * a union mixing renderable and unrenderable members would give a picker * options that show nothing, and the whole point of this module is to stop * producing a form that cannot express the call. * * A schema carrying **both** `oneOf` and `anyOf` is declined rather than * half-read. The two are independent keywords a value must satisfy *together*, * not two spellings of one union, so picking one and dropping the other builds * a form that silently omits real constraints — worse than the empty form this * module exists to replace, because it looks complete. Satisfying both honestly * means offering the cross product of their alternatives, which no real schema * has yet asked for and which produces a picker whose option labels are pairs; * until something does, declining leaves the root `properties` rendering * unchanged and claims nothing that is not true. */ export function resolveRootUnion( schema: T, ): ResolvedRootUnion { // `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, branches: [] }; } const merged = allOfMembers.reduce>( (acc, member) => mergeBranch(acc, toBranch(member) as RootUnionSchema), schema as ResolvedSchema, ); const base = withoutComposition(merged); if (schema.oneOf !== undefined && schema.anyOf !== undefined) { return { base, branches: [] }; } const members = schema.oneOf ?? schema.anyOf ?? []; const branches = members.map(toBranch); if ( branches.length === 0 || branches.some( (branch) => branch === null || !isOfferable(branch) || conflictsWithBase(base, branch), ) ) { return { base, branches: [] }; } const discriminatorProperty = schema.discriminator?.propertyName; return { base, branches: branches .filter((branch): branch is RootUnionSchema => branch !== null) .map((branch, index) => ({ // `base` is already composition-free and `mergeBranch` copies only // `properties`/`required` off the branch, so the merge stays that way. schema: mergeBranch(base, branch), label: branchLabel(branch, index, discriminatorProperty), declaredFields: Object.keys(propertiesOf(branch) ?? {}), })), }; }