fix: address Copilot review round 4 — allOf faithfulness, const null, branch-aware seeding

- rootUnion: an `allOf` member is foldable only when it states nothing beyond
  what the merge applies (`type`/`properties`/`required` plus annotations),
  so a nested `anyOf`, a `not`, or an `additionalProperties` can no longer
  be erased along with the keyword; members are checked against the
  ACCUMULATED merge, so two of them contradicting each other is caught even
  when neither contradicts the root.
- admitsNull: `const: null` admits null, so a required field pinned to it is
  no longer seeded with the one value it accepts and then reported missing.
- collectSchemaDefaults takes the values a caller is about to overlay, and
  seeds the branch THEY identify — the App deep link passes its `appArgs`, so
  branch 0's defaults can no longer sit invisibly under branch 1's arguments.
- The branch inference moves into core as `selectBranchIndex`, shared by the
  seeding and the form's picker so the two cannot disagree.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 02:24:17 -04:00
commit 7e4af5ed34

View file

@ -2,6 +2,7 @@ import { describe, it, expect } from "vitest";
import {
declaresAnyFields,
resolveRootUnion,
selectBranchIndex,
} from "@inspector/core/json/rootUnion.js";
const EMAIL = {
@ -116,6 +117,36 @@ describe("resolveRootUnion", () => {
expect(branches).toEqual([]);
});
it("leaves an allOf member carrying a constraint the merge cannot apply", () => {
// Only `properties`/`required` are merged, so a member stating anything
// further would have that constraint erased with the keyword.
for (const member of [
{ type: "object", properties: { x: {} }, additionalProperties: false },
{ type: "object", properties: { x: {} }, not: { properties: {} } },
{ type: "string", properties: { x: {} } },
]) {
const { base } = resolveRootUnion({
type: "object" as const,
allOf: [member as unknown],
});
expect(base.allOf).toHaveLength(1);
expect(base.properties).toBeUndefined();
}
});
it("declines an allOf whose members contradict each other", () => {
// Neither conflicts with the root, which declares no `x` at all.
const { base } = resolveRootUnion({
type: "object",
allOf: [
{ type: "object", properties: { x: { minimum: 10 } } },
{ type: "object", properties: { x: { minimum: 0 } } },
],
});
expect(base.allOf).toHaveLength(2);
expect(base.properties).toBeUndefined();
});
it("merges allOf branches unconditionally", () => {
const { base, branches } = resolveRootUnion({
type: "object",
@ -408,4 +439,41 @@ describe("resolveRootUnion", () => {
expect(declaresAnyFields(undefined)).toBe(false);
});
});
describe("selectBranchIndex", () => {
const { branches } = resolveRootUnion({
type: "object",
anyOf: [EMAIL, SMS],
});
it("names the branch whose discriminator the values carry", () => {
expect(selectBranchIndex(branches, { kind: "sms" })).toBe(1);
});
it("reports none when the values identify nothing", () => {
expect(selectBranchIndex(branches, {})).toBeNull();
expect(selectBranchIndex(branches, { kind: "other" })).toBeNull();
});
it("reports none when two branches match", () => {
// An ambiguous answer is worse than none: the caller falls back to the
// first branch, where the picker and the values at least agree.
const ambiguous = resolveRootUnion({
type: "object",
anyOf: [EMAIL, { ...EMAIL, properties: { ...EMAIL.properties } }],
}).branches;
expect(selectBranchIndex(ambiguous, { kind: "email" })).toBeNull();
});
it("reports none for a branch that pins nothing", () => {
const unpinned = resolveRootUnion({
type: "object",
anyOf: [
{ type: "object", properties: { a: { type: "string" } } },
{ type: "object", properties: { b: { type: "string" } } },
],
}).branches;
expect(selectBranchIndex(unpinned, { a: "x" })).toBeNull();
});
});
});