fix: address Copilot review round 20 — malformed applicators, const-null under anyOf

- rootUnion reads every composition keyword through `membersOf` and every
  `required` through `requiredOf`. These schemas describe the wire and each
  member arrives as `unknown`, so `anyOf: {}` or `required: "x"` really
  can arrive — and reading them as lists threw, taking all three clients down
  rather than declining one malformed tool. Both web and TUI required-field
  checks are guarded the same way.
- admitsNull evaluates a sibling `anyOf` beside a `const: null` instead of
  refusing outright: the union decides in both directions, and
  `{ const: null, anyOf: [{ type: "null" }] }` admits null as plainly as an
  all-string union rejects it. Refusing it left a read-only required field
  seeded with the only value it accepts and submit permanently disabled.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 07:26:49 -04:00
commit d334710d6f
2 changed files with 102 additions and 36 deletions

View file

@ -397,10 +397,13 @@ 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 &&
(schema.nullable === true || typeAdmitsNull(schema.type))
);
if (schema.nullable !== true && !typeAdmitsNull(schema.type)) return false;
// A sibling `anyOf` is conjunctive with the `const`, so it decides too —
// but it decides in *both* directions: `{ const: null, anyOf: [{ type:
// "null" }] }` admits null just as plainly as an all-string union rejects
// it. Evaluating the branches is what keeps the first case usable, since
// the field is rendered read-only and seeded with the only value it takes.
return schema.anyOf === undefined || anyOfAdmitsNull(schema);
}
if (schema.nullable === true) {
@ -421,25 +424,27 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
}
return typeNamesNull(schema.type);
}
return (
schema.anyOf?.some((entry) => {
const branch = toBranch(entry);
if (branch === null || !typeNamesNull(branch.type)) {
return false;
}
// A branch that names null can still admit nothing: `{ type: "null",
// const: "x" }` is unsatisfiable, and its own applicators are as opaque
// here as the wrapper's.
const branchSchema = branch as NullableUnionSchema;
// A branch that names null can still admit nothing: `{ type: "null",
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
// it is as opaque here as one on the wrapper.
return (
!nullExcludedBySiblings(branchSchema) &&
!hasUnevaluatedComposition(branchSchema)
);
}) ?? false
);
return anyOfAdmitsNull(schema);
}
/** Whether some `anyOf` branch is one that admits `null`. */
function anyOfAdmitsNull(schema: NullableUnionSchema): boolean {
const branches = schema.anyOf;
if (!Array.isArray(branches)) return false;
return branches.some((entry) => {
const branch = toBranch(entry);
if (branch === null || !typeNamesNull(branch.type)) {
return false;
}
// A branch that names null can still admit nothing: `{ type: "null",
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
// it is as opaque here as one on the wrapper.
const branchSchema = branch as NullableUnionSchema;
return (
!nullExcludedBySiblings(branchSchema) &&
!hasUnevaluatedComposition(branchSchema)
);
});
}
/**

View file

@ -100,6 +100,27 @@ export interface ResolvedRootUnion<T extends RootUnionSchema> {
branches: RootUnionBranch<T>[];
}
/**
* A composition keyword's members, or `[]` when the value is not a list.
*
* These annotations describe the wire, and the wire is whatever a server sent:
* the web client narrows its schema with a cast and every member arrives as
* `unknown`, so `anyOf: {}` really can reach this module. Reading it as a list
* would throw and take all three clients down with it, which is a worse answer
* than declining to flatten a schema nobody can interpret.
*/
function membersOf(value: readonly unknown[] | undefined): readonly unknown[] {
return Array.isArray(value) ? value : [];
}
/** A schema's `required`, keeping only the string entries a list-shaped one holds. */
function requiredOf(schema: RootUnionSchema): string[] {
const { required } = schema;
return Array.isArray(required)
? required.filter((name): name is string => typeof name === "string")
: [];
}
/** 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)) {
@ -441,9 +462,9 @@ export function declaresAnyFields(
// tool with `{}` on the strength of something never read.
if (schema.$ref !== undefined) return true;
const members = [
...(schema.allOf ?? []),
...(schema.anyOf ?? []),
...(schema.oneOf ?? []),
...membersOf(schema.allOf),
...membersOf(schema.anyOf),
...membersOf(schema.oneOf),
];
return members.some((member) => {
const branch = toBranch(member);
@ -479,11 +500,10 @@ function mergeBranch<T extends RootUnionSchema>(
: branchProperty,
]),
]);
const baseRequired = requiredOf(base);
const required = [
...(base.required ?? []),
...(branch.required ?? []).filter(
(name) => !(base.required ?? []).includes(name),
),
...baseRequired,
...requiredOf(branch).filter((name) => !baseRequired.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 —
@ -576,7 +596,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
// 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 allOfMembers = membersOf(schema.allOf);
let merged = schema as ResolvedSchema<T>;
for (const member of allOfMembers) {
const branch = toBranch(member);
@ -599,7 +619,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
return { base, branches: [] };
}
const isExclusiveUnion = schema.oneOf !== undefined;
const members = schema.oneOf ?? schema.anyOf ?? [];
const members = membersOf(schema.oneOf ?? schema.anyOf);
const branches = members.map(toBranch);
if (
branches.length === 0 ||
@ -745,7 +765,7 @@ export function narrowBySuppliedNames<T extends RootUnionSchema>(
const satisfied = candidates.filter((index) => {
const branch = branches[index]!;
const required = branch.schema.required ?? [];
const required = requiredOf(branch.schema);
const own = required.filter((name) => branch.declaredFields.includes(name));
return own.length > 0 && own.every((name) => supplied.has(name));
});