fix: address Copilot review round 25 — names before constants, unrenderable properties
- selectBranchIndex asks the supplied NAMES first and falls back to a lone
agreeing constant only when they settle nothing. A matching `const` does
not identify a branch while another candidate leaves that property unpinned:
`{ kind: "email", phone: "555" }` agrees with an email branch whose
`address` is missing while satisfying a phone branch outright.
- isOfferable requires every property VALUE to be readable — JSON Schema's
boolean form is fine, a `null` or an array is not a schema at all and the
web form dereferences one on the way to choosing a widget. Such a branch is
declined rather than handed on to crash a tool panel that would otherwise
have rendered.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
54c5e12b2b
commit
ab3f8c779a
4 changed files with 97 additions and 20 deletions
|
|
@ -743,9 +743,10 @@ describe("schemaToForm", () => {
|
||||||
expect(decodeFormValues({ type: "object" }, values)).toBe(values);
|
expect(decodeFormValues({ type: "object" }, values)).toBe(values);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("ignores a malformed property declaration when restoring constants", () => {
|
it("declines a union carrying a malformed property declaration", () => {
|
||||||
// `properties` values are `unknown`; a `null` entry must not throw on the
|
// `properties` values are `unknown`; a `null` entry is not a schema, so
|
||||||
// way out of the form any more than it does on the way in.
|
// the union is declined — and, the point of the test, nothing throws on
|
||||||
|
// the way out of the form any more than on the way in.
|
||||||
const schema = {
|
const schema = {
|
||||||
type: "object",
|
type: "object",
|
||||||
anyOf: [
|
anyOf: [
|
||||||
|
|
@ -756,9 +757,10 @@ describe("schemaToForm", () => {
|
||||||
{ type: "object", properties: { kind: { const: "b" } } },
|
{ type: "object", properties: { kind: { const: "b" } } },
|
||||||
] as unknown[],
|
] as unknown[],
|
||||||
};
|
};
|
||||||
|
// No union, so the form values pass through as they are.
|
||||||
expect(
|
expect(
|
||||||
decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }),
|
decodeFormValues(schema, { __variant: "0", __b0__kind: "tampered" }),
|
||||||
).toEqual({ kind: "a" });
|
).toEqual({ __variant: "0", __b0__kind: "tampered" });
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -291,11 +291,12 @@ describe("JSON Utils", () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// A `properties: { broken: null }` entry must not throw, and a branch is
|
// A `properties: { broken: null }` entry must not throw. The union is
|
||||||
// still identifiable by the discriminator that *is* well-formed.
|
// declined — a `null` is not a schema, and handing it on would crash a
|
||||||
|
// renderer — so nothing is coerced and the strings pass through.
|
||||||
expect(
|
expect(
|
||||||
convertToolParameters(malformed, { kind: "a", value: "3" }),
|
convertToolParameters(malformed, { kind: "a", value: "3" }),
|
||||||
).toEqual({ kind: "a", value: 3 });
|
).toEqual({ kind: "a", value: "3" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("falls back to the branch-agreement path when no constant is supplied (#2123)", () => {
|
it("falls back to the branch-agreement path when no constant is supplied (#2123)", () => {
|
||||||
|
|
@ -342,11 +343,11 @@ describe("JSON Utils", () => {
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
// The `null` is not a vote about the type, and must not end up standing
|
// The `null` makes the whole union unofferable, so nothing is coerced —
|
||||||
// in for one — the surviving declaration is what coerces.
|
// and, the point of the test, nothing throws either.
|
||||||
expect(
|
expect(
|
||||||
convertToolParameters(malformedDeclaration, { count: "3" }),
|
convertToolParameters(malformedDeclaration, { count: "3" }),
|
||||||
).toEqual({ count: 3 });
|
).toEqual({ count: "3" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
|
it("coerces a value whose schema lives on a root allOf branch (#2123)", () => {
|
||||||
|
|
|
||||||
|
|
@ -748,6 +748,35 @@ describe("resolveRootUnion", () => {
|
||||||
expect(branches).toEqual([]);
|
expect(branches).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("declines a member carrying a property that is not a schema", () => {
|
||||||
|
// A `null` or an array is not a schema, and the web form dereferences
|
||||||
|
// one on the way to choosing a widget — so the branch is declined rather
|
||||||
|
// than handed on to crash a tool panel.
|
||||||
|
for (const property of [null, [1]] as unknown[]) {
|
||||||
|
const { branches } = resolveRootUnion({
|
||||||
|
type: "object",
|
||||||
|
anyOf: [
|
||||||
|
EMAIL,
|
||||||
|
{ type: "object", properties: { broken: property } },
|
||||||
|
] as unknown[],
|
||||||
|
});
|
||||||
|
expect(branches).toEqual([]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("offers a member carrying a boolean property schema", () => {
|
||||||
|
// JSON Schema's boolean form is legal and answers every keyword lookup
|
||||||
|
// with `undefined`, so it renders through the JSON fallback harmlessly.
|
||||||
|
const { branches } = resolveRootUnion({
|
||||||
|
type: "object",
|
||||||
|
anyOf: [
|
||||||
|
{ type: "object", properties: { anything: true } },
|
||||||
|
{ type: "object", properties: { other: { type: "string" } } },
|
||||||
|
] as unknown[],
|
||||||
|
});
|
||||||
|
expect(branches).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
it("declines a member whose properties are not an object", () => {
|
it("declines a member whose properties are not an object", () => {
|
||||||
// Members arrive as `unknown`, so this is reachable and must not throw.
|
// Members arrive as `unknown`, so this is reachable and must not throw.
|
||||||
expect(
|
expect(
|
||||||
|
|
@ -979,6 +1008,33 @@ describe("resolveRootUnion", () => {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("prefers the branch the supplied names satisfy over a matching constant", () => {
|
||||||
|
// `{ kind: "email", phone: "555" }` agrees with the email branch's
|
||||||
|
// discriminator while missing its `address`, and satisfies the phone
|
||||||
|
// branch outright — the picker must show the one that can be called.
|
||||||
|
const mixed = resolveRootUnion({
|
||||||
|
type: "object",
|
||||||
|
anyOf: [
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: {
|
||||||
|
kind: { const: "email" },
|
||||||
|
address: { type: "string" },
|
||||||
|
},
|
||||||
|
required: ["kind", "address"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "object",
|
||||||
|
properties: { phone: { type: "string" } },
|
||||||
|
required: ["phone"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).branches;
|
||||||
|
expect(selectBranchIndex(mixed, { kind: "email", phone: "555" })).toBe(1);
|
||||||
|
// …and the constant still decides when the names settle nothing.
|
||||||
|
expect(selectBranchIndex(mixed, { kind: "email" })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
it("reports none when the values identify nothing", () => {
|
it("reports none when the values identify nothing", () => {
|
||||||
expect(selectBranchIndex(branches, {})).toBeNull();
|
expect(selectBranchIndex(branches, {})).toBeNull();
|
||||||
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();
|
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();
|
||||||
|
|
|
||||||
|
|
@ -176,10 +176,23 @@ function propertiesOf(schema: RootUnionSchema): Record<string, unknown> {
|
||||||
* as a fillable form would offer a call that cannot be valid.
|
* as a fillable form would offer a call that cannot be valid.
|
||||||
*/
|
*/
|
||||||
function isOfferable(branch: RootUnionSchema): boolean {
|
function isOfferable(branch: RootUnionSchema): boolean {
|
||||||
|
if (!hasReadableProperties(branch) || !admitsObject(branch)) return false;
|
||||||
|
const properties = Object.values(propertiesOf(branch));
|
||||||
return (
|
return (
|
||||||
hasReadableProperties(branch) &&
|
properties.length > 0 &&
|
||||||
Object.keys(propertiesOf(branch)).length > 0 &&
|
// Every value has to be something a renderer can read. JSON Schema's
|
||||||
admitsObject(branch)
|
// boolean form is legal and harmless — `true`/`false` answer every keyword
|
||||||
|
// lookup with `undefined` — but a `null` or an array is not a schema at
|
||||||
|
// all, and the web form dereferences one on the way to choosing a widget.
|
||||||
|
// A branch carrying one is declined rather than handed on to crash a tool
|
||||||
|
// panel that would otherwise have rendered.
|
||||||
|
properties.every(
|
||||||
|
(property) =>
|
||||||
|
typeof property === "boolean" ||
|
||||||
|
(typeof property === "object" &&
|
||||||
|
property !== null &&
|
||||||
|
!Array.isArray(property)),
|
||||||
|
)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -748,17 +761,22 @@ export function selectBranchIndex<T extends RootUnionSchema>(
|
||||||
if (pinned.length > 0) agreeing.push(index);
|
if (pinned.length > 0) agreeing.push(index);
|
||||||
});
|
});
|
||||||
|
|
||||||
// One branch's discriminator matched and no other's did — the plain case.
|
// What the supplied NAMES say comes first: a matching constant does not
|
||||||
if (agreeing.length === 1) return agreeing[0];
|
// identify a branch on its own while another candidate leaves that property
|
||||||
|
// unpinned — `{ kind: "email", phone: "555" }` agrees with an email branch
|
||||||
// Several branches remain — they share the constant that was supplied, or
|
// whose `address` is missing while satisfying a phone branch outright, and
|
||||||
// none was. The values still belong to a shape, so keep looking among the
|
// the picker must show the one that could actually be called.
|
||||||
// candidates by the names that were supplied.
|
const narrowed = narrowBySuppliedNames(
|
||||||
return narrowBySuppliedNames(
|
|
||||||
branches,
|
branches,
|
||||||
candidates,
|
candidates,
|
||||||
Object.keys(values).filter(supplied),
|
Object.keys(values).filter(supplied),
|
||||||
);
|
);
|
||||||
|
if (narrowed !== null) return narrowed;
|
||||||
|
|
||||||
|
// Nothing in the names settled it, so a lone agreeing constant is the last
|
||||||
|
// evidence left — a branch pinning `kind` to what was supplied says more
|
||||||
|
// than one that merely permits any value there.
|
||||||
|
return agreeing.length === 1 ? agreeing[0] : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue