fix: guard the surviving branch's own composition, via one shared predicate

Copilot round 10: one finding, the last position of the rule the previous two
rounds established. A *non-null* branch can carry its own `anyOf`, and the
hoist drops it — the branch's `anyOf` overwrites the wrapper's in the spread
and is then cleared, so `{ anyOf: [{ type: "string", anyOf: [{ const: "a" }] },
{ type: "null" }] }` widened into an unconstrained nullable string.

Rather than add a fourth inline condition, the four sites that ask "does this
schema compose something I cannot evaluate?" now share one predicate,
`hasUnevaluatedComposition` (an opaque applicator, or a nested `anyOf`):

- the surviving branch in the `anyOf` collapse (this round's finding),
- the null branch in `admitsNull`,
- the `type: [T, "null"]` collapse path,
- and, via `hasOpaqueApplicator`, the top of `admitsNull`.

Each of those was missed independently across rounds 8-10 for the same reason:
the guard existed but was spelled out inline at each site and drifted. Sharing
the predicate is the part that stops a fifth site from repeating it.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-15 20:11:59 -04:00
commit 562709a79a

View file

@ -450,6 +450,43 @@ describe("normalizeNullableUnion", () => {
expect(normalizeNullableUnion(schema)).toBe(schema);
});
// The hoist *drops* a nested union: the branch's `anyOf` overwrites the
// wrapper's in the spread and is then cleared, so the constraint vanishes and
// the field widens into an unconstrained nullable string.
it("declines when the surviving branch carries its own anyOf", () => {
const schema = {
anyOf: [
{ type: "string", anyOf: [{ const: "a" }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(schema)).toBe(schema);
});
it("declines when the surviving branch carries an opaque applicator", () => {
const withNot = {
anyOf: [
{ type: "string", not: { const: "a" } },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withNot)).toBe(withNot);
const withAllOf = {
anyOf: [
{ type: "string", allOf: [{ minLength: 2 }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withAllOf)).toBe(withAllOf);
const withOneOf = {
anyOf: [
{ type: "string", oneOf: [{ const: "a" }] },
{ type: "null" as const },
],
};
expect(normalizeNullableUnion(withOneOf)).toBe(withOneOf);
});
it("declines when the wrapper carries an applicator the module cannot read", () => {
const schema = {
allOf: [{ minLength: 2 }],