fix: make the collapse and null-admission fail closed on unevaluated schemas
Copilot round 8 opened the same conjunctive-semantics seam a third time, so
this stops patching cases and inverts the defaults instead. Both guards now
fail CLOSED: anything this module cannot evaluate sends the field to the JSON
editor rather than to a widget built from a partial reading.
1. The wrapper-conflict check enumerated the *validation* keywords and declined
when two of them disagreed — which meant any keyword nobody listed
(`oneOf`, `allOf`, `not`, `$ref`, a future vocabulary) was silently treated
as safe to drop. That whitelist fails open, and open means rendering a
widget for a constraint that is not the schema's. It is replaced by an
allowlist of *annotation* keywords: the collapse declines whenever the
wrapper carries anything beyond annotations and the union itself. Stricter
than the old equality test — a wrapper restating its branch's `type` now
declines too — which costs a dropdown on a rare redundant shape and buys a
rule with no gap in it.
2. `admitsNull` returned true for shapes it had not actually evaluated:
`not: { type: "null" }` rules null out, `allOf` can add a member that does,
`oneOf` requires *exactly one* branch to match (so a null branch does not
by itself mean null validates), and a branch like `{ type: "null",
const: "x" }` is unsatisfiable. All now answer false. Under-claiming
nullability costs a clear button; over-claiming lets the form emit a value
the schema rejects, so the asymmetry decides it.
Note the third finding — draft validity never reaching the submit controls —
is NOT fixed here, and it is worse than I described last round: `ToolDetailPanel`
gates Execute on `isExecuting` alone and never calls `hasMissingRequiredFields`,
so my earlier claim that required fields were already covered was wrong for the
Tools tab. Corrected on #2020, which is where the fix belongs.
Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
parent
987279b5d3
commit
2fdbd1905f
2 changed files with 131 additions and 77 deletions
|
|
@ -418,21 +418,24 @@ describe("normalizeNullableUnion", () => {
|
|||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
// Identical values are no conflict, so the ordinary redundant-type shape
|
||||
// still collapses rather than being punished for being explicit.
|
||||
it("collapses when wrapper and branch agree on a shared keyword", () => {
|
||||
expect(
|
||||
normalizeNullableUnion({
|
||||
type: "string",
|
||||
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" }],
|
||||
}),
|
||||
).toEqual({
|
||||
// Deliberately stricter than value-equality: a wrapper restating its branch's
|
||||
// `type` also declines. That costs a dropdown on a rare redundant shape and
|
||||
// buys a rule with no gap, which is the better trade for a module that cannot
|
||||
// evaluate JSON Schema.
|
||||
it("declines even when wrapper and branch agree on a shared keyword", () => {
|
||||
const schema = {
|
||||
type: "string",
|
||||
enum: ["a"],
|
||||
anyOf: undefined,
|
||||
// The explicit non-null `type` rules null out, conjunctively.
|
||||
nullable: false,
|
||||
});
|
||||
anyOf: [{ type: "string", enum: ["a"] }, { type: "null" as const }],
|
||||
};
|
||||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("declines when the wrapper carries an applicator the module cannot read", () => {
|
||||
const schema = {
|
||||
allOf: [{ minLength: 2 }],
|
||||
anyOf: [{ type: "string" }, { type: "null" as const }],
|
||||
};
|
||||
expect(normalizeNullableUnion(schema)).toBe(schema);
|
||||
});
|
||||
|
||||
it("treats wrapper metadata as safe to keep alongside branch constraints", () => {
|
||||
|
|
@ -484,13 +487,51 @@ describe("admitsNull", () => {
|
|||
expect(admitsNull(wide)).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes a null branch in a oneOf, and a nested type array", () => {
|
||||
expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe(
|
||||
true,
|
||||
);
|
||||
it("recognizes a null branch declared as a nested type array", () => {
|
||||
expect(admitsNull({ anyOf: [{ type: ["string", "null"] }] })).toBe(true);
|
||||
});
|
||||
|
||||
// Applicators this module does not evaluate. `oneOf` requires *exactly one*
|
||||
// branch to match, so a null branch does not by itself mean null validates;
|
||||
// `not` and `allOf` can rule it out outright. Under-claiming costs a clear
|
||||
// button, over-claiming lets the form emit a value the schema rejects.
|
||||
it("declines to claim nullability for applicators it cannot evaluate", () => {
|
||||
expect(admitsNull({ oneOf: [{ type: "string" }, { type: "null" }] })).toBe(
|
||||
false,
|
||||
);
|
||||
expect(
|
||||
admitsNull({ not: { type: "null" }, anyOf: [{ type: "null" }] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
admitsNull({ allOf: [{ type: "string" }], anyOf: [{ type: "null" }] }),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
// A branch can name null and still admit nothing.
|
||||
it("ignores an unsatisfiable null branch", () => {
|
||||
expect(
|
||||
admitsNull({ anyOf: [{ type: "string" }, { type: "null", const: "x" }] }),
|
||||
).toBe(false);
|
||||
expect(
|
||||
admitsNull({
|
||||
anyOf: [{ type: "string" }, { type: "null", enum: ["x"] }],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
admitsNull({
|
||||
anyOf: [{ type: "string" }, { type: "null", not: { type: "null" } }],
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("still accepts a plain null branch alongside an unsatisfiable one", () => {
|
||||
expect(
|
||||
admitsNull({
|
||||
anyOf: [{ type: "null", const: "x" }, { type: "null" }],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("is false for a schema that does not permit null", () => {
|
||||
expect(admitsNull({ type: "string" })).toBe(false);
|
||||
expect(admitsNull({ type: ["string", "number"] })).toBe(false);
|
||||
|
|
|
|||
|
|
@ -45,6 +45,8 @@ export interface NullableUnionSchema {
|
|||
// Read only by `admitsNull`, which considers encodings the collapse itself
|
||||
// declines to flatten, and the sibling constraints that can rule null out.
|
||||
oneOf?: readonly unknown[];
|
||||
allOf?: readonly unknown[];
|
||||
not?: unknown;
|
||||
nullable?: boolean;
|
||||
const?: unknown;
|
||||
}
|
||||
|
|
@ -127,69 +129,63 @@ function nullExcludedBySiblings(schema: NullableUnionSchema): boolean {
|
|||
}
|
||||
|
||||
/**
|
||||
* Keywords that *constrain what values are valid*, as opposed to describing the
|
||||
* field (`title`, `description`, `default`, `enumNames`).
|
||||
* Keywords that only *describe* a field. Everything else constrains what values
|
||||
* are valid.
|
||||
*
|
||||
* Only these can conflict in a way that matters: a wrapper and its branch each
|
||||
* carrying one means the value must satisfy **both**, which the hoist cannot
|
||||
* express. Metadata is safe to merge because nothing validates against it.
|
||||
* This is an **allowlist on purpose**, and the direction matters. The previous
|
||||
* version enumerated the *validation* keywords and declined on a conflict among
|
||||
* them, which meant any keyword nobody thought of — `oneOf`, `allOf`, `not`,
|
||||
* `$ref`, a future vocabulary — was silently treated as safe to drop. A missing
|
||||
* entry there fails open, and open means rendering a widget for a constraint
|
||||
* that is not the schema's. Listing the harmless keys instead fails closed: an
|
||||
* unrecognized keyword sends the field to the JSON editor, which is the correct
|
||||
* answer for a schema this module does not understand.
|
||||
*/
|
||||
const VALIDATION_KEYWORDS = [
|
||||
"type",
|
||||
"enum",
|
||||
"const",
|
||||
"items",
|
||||
"properties",
|
||||
"required",
|
||||
"additionalProperties",
|
||||
"minimum",
|
||||
"maximum",
|
||||
"exclusiveMinimum",
|
||||
"exclusiveMaximum",
|
||||
"multipleOf",
|
||||
"minLength",
|
||||
"maxLength",
|
||||
"pattern",
|
||||
"format",
|
||||
"minItems",
|
||||
"maxItems",
|
||||
"uniqueItems",
|
||||
] as const;
|
||||
const ANNOTATION_KEYWORDS = new Set([
|
||||
"title",
|
||||
"description",
|
||||
"default",
|
||||
"enumNames",
|
||||
"examples",
|
||||
"deprecated",
|
||||
"readOnly",
|
||||
"writeOnly",
|
||||
"$comment",
|
||||
"$schema",
|
||||
"$id",
|
||||
]);
|
||||
|
||||
/**
|
||||
* Whether hoisting this branch would *drop* one of the wrapper's constraints.
|
||||
* Whether hoisting a branch would *drop* something the wrapper constrains.
|
||||
*
|
||||
* The hoist is a spread, so a branch key replaces the wrapper's — but JSON
|
||||
* Schema applies sibling keywords **conjunctively**, and a replacement is not a
|
||||
* conjunction. A wrapper `enum: ["a"]` around a branch `enum: ["a", "b"]` means
|
||||
* `"a"` (both must hold), yet the spread yields `["a", "b"]` and the rendered
|
||||
* dropdown would offer — and submit — a `"b"` the schema rejects. `type`,
|
||||
* bounds, and the object keywords fail the same way.
|
||||
* bounds, the object keywords, and the applicators (`oneOf`, `allOf`, `not`)
|
||||
* all fail the same way.
|
||||
*
|
||||
* Rather than implement intersection for every keyword (a much larger surface,
|
||||
* and one where a subtly wrong intersection is worse than none), the collapse
|
||||
* **declines** when wrapper and branch disagree, and the field renders through
|
||||
* the JSON editor with its full schema intact. Identical values are not a
|
||||
* conflict, so the common `{ type: "string", anyOf: [{ type: "string", … }] }`
|
||||
* still collapses.
|
||||
* Rather than intersect them — a much larger surface, and one where a subtly
|
||||
* wrong intersection is worse than none because it renders a *plausible* widget
|
||||
* for the wrong constraint — the collapse **declines** whenever the wrapper
|
||||
* carries anything beyond annotations and the union itself. The field then
|
||||
* renders through the JSON editor with its full schema intact.
|
||||
*
|
||||
* Comparison is by canonical JSON, so two structurally equal objects written
|
||||
* with different key order read as a conflict. That is conservative in the safe
|
||||
* direction: the field falls back rather than mis-renders.
|
||||
* This is stricter than comparing values for equality: a wrapper that merely
|
||||
* restates its branch's `type` now also declines. That costs a dropdown on a
|
||||
* rare redundant shape and buys a rule with no gap in it, which is the better
|
||||
* trade for a module that cannot evaluate JSON Schema.
|
||||
*/
|
||||
function branchDropsWrapperConstraint(
|
||||
schema: NullableUnionSchema,
|
||||
branch: Record<string, unknown>,
|
||||
): boolean {
|
||||
const wrapper = schema as Record<string, unknown>;
|
||||
return VALIDATION_KEYWORDS.some((keyword) => {
|
||||
const wrapperValue = wrapper[keyword];
|
||||
const branchValue = branch[keyword];
|
||||
if (wrapperValue === undefined || branchValue === undefined) {
|
||||
return false;
|
||||
}
|
||||
return JSON.stringify(wrapperValue) !== JSON.stringify(branchValue);
|
||||
});
|
||||
function wrapperCarriesConstraints(schema: NullableUnionSchema): boolean {
|
||||
return Object.keys(schema).some(
|
||||
(key) => key !== "anyOf" && !ANNOTATION_KEYWORDS.has(key),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether a schema's `type` names `"null"` at all. */
|
||||
function typeNamesNull(type: unknown): boolean {
|
||||
return Array.isArray(type) ? type.includes("null") : type === "null";
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -313,6 +309,19 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
|||
if (nullExcludedBySiblings(schema)) {
|
||||
return false;
|
||||
}
|
||||
// Applicators this module does not evaluate. `not: { type: "null" }` rules
|
||||
// null out; `allOf` can add a member that does; and `oneOf` requires
|
||||
// **exactly one** branch to match, so a null branch does not by itself mean
|
||||
// `null` validates — two matching branches make it fail. Rather than answer
|
||||
// these half-correctly, say no: under-claiming nullability costs a clear
|
||||
// button, while over-claiming lets the form emit a value the schema rejects.
|
||||
if (
|
||||
schema.not !== undefined ||
|
||||
schema.allOf !== undefined ||
|
||||
schema.oneOf !== undefined
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (schema.nullable === true) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -322,21 +331,25 @@ export function admitsNull(schema: NullableUnionSchema): boolean {
|
|||
// as the union. Falling through to the branch scan here would let the union
|
||||
// override a constraint that outranks it.
|
||||
if (schema.type !== undefined) {
|
||||
return Array.isArray(schema.type)
|
||||
? schema.type.includes("null")
|
||||
: schema.type === "null";
|
||||
return typeNamesNull(schema.type);
|
||||
}
|
||||
return [schema.anyOf, schema.oneOf].some((branches) =>
|
||||
branches?.some((entry) => {
|
||||
return (
|
||||
schema.anyOf?.some((entry) => {
|
||||
const branch = toBranch(entry);
|
||||
if (branch === null) {
|
||||
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;
|
||||
return (
|
||||
branch.type === "null" ||
|
||||
(Array.isArray(branch.type) && branch.type.includes("null"))
|
||||
!nullExcludedBySiblings(branchSchema) &&
|
||||
branchSchema.not === undefined &&
|
||||
branchSchema.allOf === undefined &&
|
||||
branchSchema.oneOf === undefined
|
||||
);
|
||||
}),
|
||||
}) ?? false
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -375,7 +388,7 @@ export function normalizeNullableUnion<T extends NullableUnionSchema>(
|
|||
|
||||
// Hoisting would silently drop a wrapper constraint the branch also
|
||||
// carries, so decline instead; see `branchDropsWrapperConstraint`.
|
||||
if (nullBranch && branch && !branchDropsWrapperConstraint(schema, branch)) {
|
||||
if (nullBranch && branch && !wrapperCarriesConstraints(schema)) {
|
||||
// A branch may carry an `enum` and no `type`; JSON Schema allows that, and
|
||||
// an all-string enum is unambiguously a string field. See isStringEnum for
|
||||
// why a non-string enum deliberately does not get the same treatment.
|
||||
|
|
|
|||
Loading…
Reference in a new issue