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

@ -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", () => {
// `const` is conjunctive with its siblings, not an override: both of
// these reject every value, so claiming nullability would let the

View file

@ -664,6 +664,35 @@ describe("resolveRootUnion", () => {
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", () => {
expect(resolveRootUnion({ type: "object", anyOf: [] }).branches).toEqual(
[],