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:
parent
44b2ccd257
commit
d334710d6f
6 changed files with 102 additions and 36 deletions
|
|
@ -322,7 +322,9 @@ function applyConstants<T>(
|
||||||
function buildFields(schema: JsonSchemaObject): FormField[] {
|
function buildFields(schema: JsonSchemaObject): FormField[] {
|
||||||
const fields: FormField[] = [];
|
const fields: FormField[] = [];
|
||||||
const properties = schema.properties || {};
|
const properties = schema.properties || {};
|
||||||
const required = schema.required || [];
|
// `Array.isArray`, not `|| []`: a nonconforming server can send
|
||||||
|
// `required: "x"`, and `.includes` on a string silently matches substrings.
|
||||||
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||||
|
|
||||||
for (const [key, prop] of Object.entries(properties)) {
|
for (const [key, prop] of Object.entries(properties)) {
|
||||||
// `properties` values are `unknown` (the SDK schema admits anything), so
|
// `properties` values are `unknown` (the SDK schema admits anything), so
|
||||||
|
|
@ -463,7 +465,8 @@ export function missingRequiredFields(
|
||||||
? base
|
? base
|
||||||
: branches[selectedBranchIndex(base, branches, rawValues)]!.schema;
|
: branches[selectedBranchIndex(base, branches, rawValues)]!.schema;
|
||||||
const properties = effective.properties ?? {};
|
const properties = effective.properties ?? {};
|
||||||
return (effective.required ?? []).filter((name) => {
|
const required = Array.isArray(effective.required) ? effective.required : [];
|
||||||
|
return required.filter((name) => {
|
||||||
// `hasOwn` first: an argument legally named `constructor` would otherwise
|
// `hasOwn` first: an argument legally named `constructor` would otherwise
|
||||||
// resolve to the inherited one and read as supplied, and the call would go
|
// resolve to the inherited one and read as supplied, and the call would go
|
||||||
// out without it.
|
// out without it.
|
||||||
|
|
|
||||||
|
|
@ -745,6 +745,12 @@ describe("admitsNull", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("admits null when a sibling anyOf branch does", () => {
|
||||||
|
// The union decides in both directions: an all-string one rejects null,
|
||||||
|
// and a null branch admits it just as plainly.
|
||||||
|
expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
it("does not override a sibling that rejects null", () => {
|
it("does not override a sibling that rejects null", () => {
|
||||||
// `const` is conjunctive with its siblings, not an override: both of
|
// `const` is conjunctive with its siblings, not an override: both of
|
||||||
// these reject every value, so claiming nullability would let the
|
// these reject every value, so claiming nullability would let the
|
||||||
|
|
|
||||||
|
|
@ -664,6 +664,35 @@ describe("resolveRootUnion", () => {
|
||||||
expect(branches).toEqual([]);
|
expect(branches).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("survives a composition keyword that is not a list", () => {
|
||||||
|
// These schemas describe the wire, and every member arrives as `unknown`
|
||||||
|
// — reading `anyOf: {}` as a list would throw and take all three clients
|
||||||
|
// down rather than declining one malformed tool.
|
||||||
|
const { base, branches } = resolveRootUnion({
|
||||||
|
type: "object",
|
||||||
|
properties: { a: { type: "string" } },
|
||||||
|
anyOf: {} as unknown as unknown[],
|
||||||
|
});
|
||||||
|
expect(branches).toEqual([]);
|
||||||
|
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a required that is not a list", () => {
|
||||||
|
const { branches } = resolveRootUnion({
|
||||||
|
type: "object",
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { a: { type: "string" } },
|
||||||
|
required: "a",
|
||||||
|
},
|
||||||
|
{ type: "object", properties: { b: { type: "string" } } },
|
||||||
|
] as unknown[],
|
||||||
|
});
|
||||||
|
expect(branches).toHaveLength(2);
|
||||||
|
expect(branches[0].schema.required).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
it("declines an empty union", () => {
|
it("declines an empty union", () => {
|
||||||
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
|
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
|
||||||
[],
|
[],
|
||||||
|
|
|
||||||
|
|
@ -284,7 +284,10 @@ function hasMissingIn(
|
||||||
schema: InspectorFormSchema,
|
schema: InspectorFormSchema,
|
||||||
values: Record<string, unknown>,
|
values: Record<string, unknown>,
|
||||||
): boolean {
|
): boolean {
|
||||||
const required = schema.required ?? [];
|
// `Array.isArray`, not `?? []`: these schemas describe the wire, and a
|
||||||
|
// nonconforming server can send `required: "x"` — which `.some` would throw
|
||||||
|
// on, taking the whole panel down rather than one malformed tool.
|
||||||
|
const required = Array.isArray(schema.required) ? schema.required : [];
|
||||||
const properties = schema.properties ?? {};
|
const properties = schema.properties ?? {};
|
||||||
return required.some((field) => {
|
return required.some((field) => {
|
||||||
const value = values[field];
|
const value = values[field];
|
||||||
|
|
|
||||||
|
|
@ -397,10 +397,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
||||||
// would let the gate accept a `null` the schema forbids. The opaque
|
// would let the gate accept a `null` the schema forbids. The opaque
|
||||||
// applicators (`not`, `allOf`, `oneOf`) are already refused above.
|
// applicators (`not`, `allOf`, `oneOf`) are already refused above.
|
||||||
if (schema.const === null) {
|
if (schema.const === null) {
|
||||||
return (
|
if (schema.nullable !== true && !typeAdmitsNull(schema.type)) return false;
|
||||||
schema.anyOf === undefined &&
|
// A sibling `anyOf` is conjunctive with the `const`, so it decides too —
|
||||||
(schema.nullable === true || typeAdmitsNull(schema.type))
|
// 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) {
|
if (schema.nullable === true) {
|
||||||
|
|
@ -421,25 +424,27 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
||||||
}
|
}
|
||||||
return typeNamesNull(schema.type);
|
return typeNamesNull(schema.type);
|
||||||
}
|
}
|
||||||
return (
|
return anyOfAdmitsNull(schema);
|
||||||
schema.anyOf?.some((entry) => {
|
}
|
||||||
const branch = toBranch(entry);
|
|
||||||
if (branch === null || !typeNamesNull(branch.type)) {
|
/** Whether some `anyOf` branch is one that admits `null`. */
|
||||||
return false;
|
function anyOfAdmitsNull(schema: NullableUnionSchema): boolean {
|
||||||
}
|
const branches = schema.anyOf;
|
||||||
// A branch that names null can still admit nothing: `{ type: "null",
|
if (!Array.isArray(branches)) return false;
|
||||||
// const: "x" }` is unsatisfiable, and its own applicators are as opaque
|
return branches.some((entry) => {
|
||||||
// here as the wrapper's.
|
const branch = toBranch(entry);
|
||||||
const branchSchema = branch as NullableUnionSchema;
|
if (branch === null || !typeNamesNull(branch.type)) {
|
||||||
// A branch that names null can still admit nothing: `{ type: "null",
|
return false;
|
||||||
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
|
}
|
||||||
// it is as opaque here as one on the wrapper.
|
// A branch that names null can still admit nothing: `{ type: "null",
|
||||||
return (
|
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
|
||||||
!nullExcludedBySiblings(branchSchema) &&
|
// it is as opaque here as one on the wrapper.
|
||||||
!hasUnevaluatedComposition(branchSchema)
|
const branchSchema = branch as NullableUnionSchema;
|
||||||
);
|
return (
|
||||||
}) ?? false
|
!nullExcludedBySiblings(branchSchema) &&
|
||||||
);
|
!hasUnevaluatedComposition(branchSchema)
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,27 @@ export interface ResolvedRootUnion<T extends RootUnionSchema> {
|
||||||
branches: RootUnionBranch<T>[];
|
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. */
|
/** Narrow a composition member to a readable object, or `null` if it isn't one. */
|
||||||
function toBranch(value: unknown): RootUnionSchema | null {
|
function toBranch(value: unknown): RootUnionSchema | null {
|
||||||
if (typeof value !== "object" || value === null || Array.isArray(value)) {
|
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.
|
// tool with `{}` on the strength of something never read.
|
||||||
if (schema.$ref !== undefined) return true;
|
if (schema.$ref !== undefined) return true;
|
||||||
const members = [
|
const members = [
|
||||||
...(schema.allOf ?? []),
|
...membersOf(schema.allOf),
|
||||||
...(schema.anyOf ?? []),
|
...membersOf(schema.anyOf),
|
||||||
...(schema.oneOf ?? []),
|
...membersOf(schema.oneOf),
|
||||||
];
|
];
|
||||||
return members.some((member) => {
|
return members.some((member) => {
|
||||||
const branch = toBranch(member);
|
const branch = toBranch(member);
|
||||||
|
|
@ -479,11 +500,10 @@ function mergeBranch<T extends RootUnionSchema>(
|
||||||
: branchProperty,
|
: branchProperty,
|
||||||
]),
|
]),
|
||||||
]);
|
]);
|
||||||
|
const baseRequired = requiredOf(base);
|
||||||
const required = [
|
const required = [
|
||||||
...(base.required ?? []),
|
...baseRequired,
|
||||||
...(branch.required ?? []).filter(
|
...requiredOf(branch).filter((name) => !baseRequired.includes(name)),
|
||||||
(name) => !(base.required ?? []).includes(name),
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
// One cast, owned here: a branch member is `unknown` on the wire, so its
|
// 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 —
|
// 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
|
// 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
|
// branch would otherwise be merged against a base whose constraints are not
|
||||||
// all known.
|
// all known.
|
||||||
const allOfMembers = schema.allOf ?? [];
|
const allOfMembers = membersOf(schema.allOf);
|
||||||
let merged = schema as ResolvedSchema<T>;
|
let merged = schema as ResolvedSchema<T>;
|
||||||
for (const member of allOfMembers) {
|
for (const member of allOfMembers) {
|
||||||
const branch = toBranch(member);
|
const branch = toBranch(member);
|
||||||
|
|
@ -599,7 +619,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
|
||||||
return { base, branches: [] };
|
return { base, branches: [] };
|
||||||
}
|
}
|
||||||
const isExclusiveUnion = schema.oneOf !== undefined;
|
const isExclusiveUnion = schema.oneOf !== undefined;
|
||||||
const members = schema.oneOf ?? schema.anyOf ?? [];
|
const members = membersOf(schema.oneOf ?? schema.anyOf);
|
||||||
const branches = members.map(toBranch);
|
const branches = members.map(toBranch);
|
||||||
if (
|
if (
|
||||||
branches.length === 0 ||
|
branches.length === 0 ||
|
||||||
|
|
@ -745,7 +765,7 @@ export function narrowBySuppliedNames<T extends RootUnionSchema>(
|
||||||
|
|
||||||
const satisfied = candidates.filter((index) => {
|
const satisfied = candidates.filter((index) => {
|
||||||
const branch = branches[index]!;
|
const branch = branches[index]!;
|
||||||
const required = branch.schema.required ?? [];
|
const required = requiredOf(branch.schema);
|
||||||
const own = required.filter((name) => branch.declaredFields.includes(name));
|
const own = required.filter((name) => branch.declaredFields.includes(name));
|
||||||
return own.length > 0 && own.every((name) => supplied.has(name));
|
return own.length > 0 && own.every((name) => supplied.has(name));
|
||||||
});
|
});
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue