Skip to content

Commit d334710

Browse files
committed
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>
1 parent 44b2ccd commit d334710

6 files changed

Lines changed: 102 additions & 36 deletions

File tree

‎clients/tui/src/utils/schemaToForm.ts‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,9 @@ function applyConstants<T>(
322322
function buildFields(schema: JsonSchemaObject): FormField[] {
323323
const fields: FormField[] = [];
324324
const properties = schema.properties || {};
325-
const required = schema.required || [];
325+
// `Array.isArray`, not `|| []`: a nonconforming server can send
326+
// `required: "x"`, and `.includes` on a string silently matches substrings.
327+
const required = Array.isArray(schema.required) ? schema.required : [];
326328

327329
for (const [key, prop] of Object.entries(properties)) {
328330
// `properties` values are `unknown` (the SDK schema admits anything), so
@@ -463,7 +465,8 @@ export function missingRequiredFields(
463465
? base
464466
: branches[selectedBranchIndex(base, branches, rawValues)]!.schema;
465467
const properties = effective.properties ?? {};
466-
return (effective.required ?? []).filter((name) => {
468+
const required = Array.isArray(effective.required) ? effective.required : [];
469+
return required.filter((name) => {
467470
// `hasOwn` first: an argument legally named `constructor` would otherwise
468471
// resolve to the inherited one and read as supplied, and the call would go
469472
// out without it.

‎clients/web/src/test/core/nullableUnion.test.ts‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -745,6 +745,12 @@ describe("admitsNull", () => {
745745
);
746746
});
747747

748+
it("admits null when a sibling anyOf branch does", () => {
749+
// The union decides in both directions: an all-string one rejects null,
750+
// and a null branch admits it just as plainly.
751+
expect(admitsNull({ const: null, anyOf: [{ type: "null" }] })).toBe(true);
752+
});
753+
748754
it("does not override a sibling that rejects null", () => {
749755
// `const` is conjunctive with its siblings, not an override: both of
750756
// these reject every value, so claiming nullability would let the

‎clients/web/src/test/core/rootUnion.test.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -664,6 +664,35 @@ describe("resolveRootUnion", () => {
664664
expect(branches).toEqual([]);
665665
});
666666

667+
it("survives a composition keyword that is not a list", () => {
668+
// These schemas describe the wire, and every member arrives as `unknown`
669+
// — reading `anyOf: {}` as a list would throw and take all three clients
670+
// down rather than declining one malformed tool.
671+
const { base, branches } = resolveRootUnion({
672+
type: "object",
673+
properties: { a: { type: "string" } },
674+
anyOf: {} as unknown as unknown[],
675+
});
676+
expect(branches).toEqual([]);
677+
expect(Object.keys(base.properties ?? {})).toEqual(["a"]);
678+
});
679+
680+
it("survives a required that is not a list", () => {
681+
const { branches } = resolveRootUnion({
682+
type: "object",
683+
anyOf: [
684+
{
685+
type: "object",
686+
properties: { a: { type: "string" } },
687+
required: "a",
688+
},
689+
{ type: "object", properties: { b: { type: "string" } } },
690+
] as unknown[],
691+
});
692+
expect(branches).toHaveLength(2);
693+
expect(branches[0].schema.required).toBeUndefined();
694+
});
695+
667696
it("declines an empty union", () => {
668697
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
669698
[],

‎clients/web/src/utils/jsonUtils.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -284,7 +284,10 @@ function hasMissingIn(
284284
schema: InspectorFormSchema,
285285
values: Record<string, unknown>,
286286
): boolean {
287-
const required = schema.required ?? [];
287+
// `Array.isArray`, not `?? []`: these schemas describe the wire, and a
288+
// nonconforming server can send `required: "x"` — which `.some` would throw
289+
// on, taking the whole panel down rather than one malformed tool.
290+
const required = Array.isArray(schema.required) ? schema.required : [];
288291
const properties = schema.properties ?? {};
289292
return required.some((field) => {
290293
const value = values[field];

‎core/json/nullableUnion.ts‎

Lines changed: 28 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -397,10 +397,13 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
397397
// would let the gate accept a `null` the schema forbids. The opaque
398398
// applicators (`not`, `allOf`, `oneOf`) are already refused above.
399399
if (schema.const === null) {
400-
return (
401-
schema.anyOf === undefined &&
402-
(schema.nullable === true || typeAdmitsNull(schema.type))
403-
);
400+
if (schema.nullable !== true && !typeAdmitsNull(schema.type)) return false;
401+
// A sibling `anyOf` is conjunctive with the `const`, so it decides too —
402+
// but it decides in *both* directions: `{ const: null, anyOf: [{ type:
403+
// "null" }] }` admits null just as plainly as an all-string union rejects
404+
// it. Evaluating the branches is what keeps the first case usable, since
405+
// the field is rendered read-only and seeded with the only value it takes.
406+
return schema.anyOf === undefined || anyOfAdmitsNull(schema);
404407
}
405408

406409
if (schema.nullable === true) {
@@ -421,25 +424,27 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
421424
}
422425
return typeNamesNull(schema.type);
423426
}
424-
return (
425-
schema.anyOf?.some((entry) => {
426-
const branch = toBranch(entry);
427-
if (branch === null || !typeNamesNull(branch.type)) {
428-
return false;
429-
}
430-
// A branch that names null can still admit nothing: `{ type: "null",
431-
// const: "x" }` is unsatisfiable, and its own applicators are as opaque
432-
// here as the wrapper's.
433-
const branchSchema = branch as NullableUnionSchema;
434-
// A branch that names null can still admit nothing: `{ type: "null",
435-
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
436-
// it is as opaque here as one on the wrapper.
437-
return (
438-
!nullExcludedBySiblings(branchSchema) &&
439-
!hasUnevaluatedComposition(branchSchema)
440-
);
441-
}) ?? false
442-
);
427+
return anyOfAdmitsNull(schema);
428+
}
429+
430+
/** Whether some `anyOf` branch is one that admits `null`. */
431+
function anyOfAdmitsNull(schema: NullableUnionSchema): boolean {
432+
const branches = schema.anyOf;
433+
if (!Array.isArray(branches)) return false;
434+
return branches.some((entry) => {
435+
const branch = toBranch(entry);
436+
if (branch === null || !typeNamesNull(branch.type)) {
437+
return false;
438+
}
439+
// A branch that names null can still admit nothing: `{ type: "null",
440+
// const: "x" }` is unsatisfiable, and a nested union or applicator inside
441+
// it is as opaque here as one on the wrapper.
442+
const branchSchema = branch as NullableUnionSchema;
443+
return (
444+
!nullExcludedBySiblings(branchSchema) &&
445+
!hasUnevaluatedComposition(branchSchema)
446+
);
447+
});
443448
}
444449

445450
/**

‎core/json/rootUnion.ts‎

Lines changed: 30 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,27 @@ export interface ResolvedRootUnion<T extends RootUnionSchema> {
100100
branches: RootUnionBranch<T>[];
101101
}
102102

103+
/**
104+
* A composition keyword's members, or `[]` when the value is not a list.
105+
*
106+
* These annotations describe the wire, and the wire is whatever a server sent:
107+
* the web client narrows its schema with a cast and every member arrives as
108+
* `unknown`, so `anyOf: {}` really can reach this module. Reading it as a list
109+
* would throw and take all three clients down with it, which is a worse answer
110+
* than declining to flatten a schema nobody can interpret.
111+
*/
112+
function membersOf(value: readonly unknown[] | undefined): readonly unknown[] {
113+
return Array.isArray(value) ? value : [];
114+
}
115+
116+
/** A schema's `required`, keeping only the string entries a list-shaped one holds. */
117+
function requiredOf(schema: RootUnionSchema): string[] {
118+
const { required } = schema;
119+
return Array.isArray(required)
120+
? required.filter((name): name is string => typeof name === "string")
121+
: [];
122+
}
123+
103124
/** Narrow a composition member to a readable object, or `null` if it isn't one. */
104125
function toBranch(value: unknown): RootUnionSchema | null {
105126
if (typeof value !== "object" || value === null || Array.isArray(value)) {
@@ -441,9 +462,9 @@ export function declaresAnyFields(
441462
// tool with `{}` on the strength of something never read.
442463
if (schema.$ref !== undefined) return true;
443464
const members = [
444-
...(schema.allOf ?? []),
445-
...(schema.anyOf ?? []),
446-
...(schema.oneOf ?? []),
465+
...membersOf(schema.allOf),
466+
...membersOf(schema.anyOf),
467+
...membersOf(schema.oneOf),
447468
];
448469
return members.some((member) => {
449470
const branch = toBranch(member);
@@ -479,11 +500,10 @@ function mergeBranch<T extends RootUnionSchema>(
479500
: branchProperty,
480501
]),
481502
]);
503+
const baseRequired = requiredOf(base);
482504
const required = [
483-
...(base.required ?? []),
484-
...(branch.required ?? []).filter(
485-
(name) => !(base.required ?? []).includes(name),
486-
),
505+
...baseRequired,
506+
...requiredOf(branch).filter((name) => !baseRequired.includes(name)),
487507
];
488508
// One cast, owned here: a branch member is `unknown` on the wire, so its
489509
// property schemas are whatever the server sent however `T` declares them —
@@ -576,7 +596,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
576596
// composition keywords stay on it, and no union is offered either, since a
577597
// branch would otherwise be merged against a base whose constraints are not
578598
// all known.
579-
const allOfMembers = schema.allOf ?? [];
599+
const allOfMembers = membersOf(schema.allOf);
580600
let merged = schema as ResolvedSchema<T>;
581601
for (const member of allOfMembers) {
582602
const branch = toBranch(member);
@@ -599,7 +619,7 @@ export function resolveRootUnion<T extends RootUnionSchema>(
599619
return { base, branches: [] };
600620
}
601621
const isExclusiveUnion = schema.oneOf !== undefined;
602-
const members = schema.oneOf ?? schema.anyOf ?? [];
622+
const members = membersOf(schema.oneOf ?? schema.anyOf);
603623
const branches = members.map(toBranch);
604624
if (
605625
branches.length === 0 ||
@@ -745,7 +765,7 @@ export function narrowBySuppliedNames<T extends RootUnionSchema>(
745765

746766
const satisfied = candidates.filter((index) => {
747767
const branch = branches[index]!;
748-
const required = branch.schema.required ?? [];
768+
const required = requiredOf(branch.schema);
749769
const own = required.filter((name) => branch.declaredFields.includes(name));
750770
return own.length > 0 && own.every((name) => supplied.has(name));
751771
});

0 commit comments

Comments
 (0)