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
2 changed files with 188 additions and 51 deletions

View file

@ -31,7 +31,10 @@ import {
isStringEnum,
normalizeNullableUnion,
} from "@inspector/core/json/nullableUnion.js";
import { resolveRootUnion } from "@inspector/core/json/rootUnion.js";
import {
resolveRootUnion,
selectBranchIndex,
} from "@inspector/core/json/rootUnion.js";
import { collectSchemaDefaults } from "../../../utils/jsonUtils";
const FieldLabel = Text.withProps({
@ -571,36 +574,6 @@ function resolveValue(
return getDefaultValue(fieldSchema);
}
/**
* The branch a set of values already identifies, or `null` when they identify
* none uniquely.
*
* A discriminated union pins its discriminator with `const`, so values carrying
* one name the branch they belong to. This matters because the form is not
* always mounted empty: an App deep link overlays `appArgs` on the initial
* defaults, so `{ kind: "sms", … }` can arrive while the picker would otherwise
* open on the first branch — showing one shape's controls while a differently
* shaped set of values sits underneath, ready to be submitted (#2123).
*/
function matchBranchIndex(
branches: { schema: InspectorFormSchema }[],
values: Record<string, unknown>,
): number | null {
const matches: number[] = [];
branches.forEach((branch, index) => {
const pinned = Object.entries(branch.schema.properties ?? {}).filter(
([, fieldSchema]) => fieldSchema.const !== undefined,
);
if (
pinned.length > 0 &&
pinned.every(([name, fieldSchema]) => values[name] === fieldSchema.const)
) {
matches.push(index);
}
});
return matches.length === 1 ? matches[0] : null;
}
export function SchemaForm({
schema,
values,
@ -619,7 +592,7 @@ export function SchemaForm({
// property of this rendering, not of the arguments: `values` carries what the
// user typed, and nothing in it names a branch.
const [branchIndex, setBranchIndex] = useState(
() => matchBranchIndex(branches, values) ?? 0,
() => selectBranchIndex(branches, values) ?? 0,
);
// A form reused for another entity can be handed a shorter union, so the
// index is clamped rather than trusted — `resetKey` resets it below, but a
@ -657,7 +630,7 @@ export function SchemaForm({
// same reason enlargement does — reset to whichever branch the new values
// identify, so the visible selection cannot disagree with what would be
// submitted, and to the first when they identify none.
setBranchIndex(matchBranchIndex(branches, values) ?? 0);
setBranchIndex(selectBranchIndex(branches, values) ?? 0);
});
// Stable so a field's reporting effect subscribes once, not per render. The

View file

@ -1035,8 +1035,16 @@ export function InspectorView({
// value is absent from `formValues`, the schema-form's validity check
// fails, and Open App is silently disabled — an automated driver's click
// then no-ops and the iframe-wait spins forever.
// The args are passed to the seeding too, not just overlaid on it: for a
// schema whose arguments are a root union they can name a branch other
// than the first, and defaults seeded from the wrong branch would sit in
// the submitted arguments where the form — showing the branch the args
// identify — never displays them (#2123).
const formValues = {
...collectSchemaDefaults(toFormSchema(target.inputSchema) ?? {}),
...collectSchemaDefaults(
toFormSchema(target.inputSchema) ?? {},
deepLink.appArgs ?? {},
),
...deepLink.appArgs,
};
// Seed the selection directly rather than routing through