fix: address Copilot review round 3 — conjunctive keywords, unflattenable allOf, branch inference

- rootUnion: a branch restating a constraint the root states differently is
  declined (keywords at one level are conjunctive and no intersection is
  computed here), while annotations may disagree freely. An `allOf` with a
  member it cannot fold in — JSON Schema's boolean form, or a $ref whose
  constraints are unknown rather than absent — is left intact instead of
  merged-and-stripped, which would turn an unsatisfiable schema into a
  fillable form.
- rootUnion: `declaresAnyFields` counts the raw composition members, so
  `hasInputFields` reports fields for a union the resolver declines and an
  App tool is never auto-invoked with empty arguments.
- SchemaForm: the picker opens on the branch the supplied values identify
  (an App deep link overlays its args, so they can name another branch), and
  a branch switch no longer carries a value the outgoing branch declared —
  the branches may type the same name differently.
- TUI: a base property some branch redeclares is offered in EVERY branch's
  section under that branch's inherited or specialized declaration, so a
  branch that does not redeclare it can still supply a required root
  argument.

Signed-off-by: cliffhall <cliff@futurescale.com>
This commit is contained in:
cliffhall 2026-08-26 02:01:44 -04:00
commit 1a271e4931
2 changed files with 442 additions and 69 deletions

View file

@ -2100,6 +2100,55 @@ describe("SchemaForm multiline strings (#2042)", () => {
expect(mode.value).toBe("fast");
});
it("opens on the branch the supplied values identify", () => {
// A deep link overlays its args on the initial defaults, so values for
// one branch can arrive while the picker would otherwise open on another.
renderWithMantine(
<SchemaForm
schema={UNION_SCHEMA}
values={{ kind: "sms", phone: "555" }}
onChange={vi.fn()}
/>,
);
expect(
(screen.getByRole("textbox", { name: /Variant/ }) as HTMLInputElement)
.value,
).toBe("sms");
expect(screen.getByRole("textbox", { name: /Phone/ })).toBeTruthy();
});
it("does not carry a value the outgoing branch declared", async () => {
const user = userEvent.setup();
const onChange = vi.fn();
const schema: InspectorFormSchema = {
type: "object",
anyOf: [
{
type: "object",
title: "A",
properties: { value: { type: "number", title: "Value" } },
},
{
type: "object",
title: "B",
properties: { value: { type: "boolean", title: "Value" } },
},
],
};
renderWithMantine(
<SchemaForm
schema={schema}
values={{ value: 3 }}
onChange={onChange}
/>,
);
await user.click(screen.getByRole("textbox", { name: /Variant/ }));
await user.click(screen.getByRole("option", { name: "B" }));
// Branch B types `value` as a boolean; carrying the 3 would check the box
// and submit a number the branch rejects.
expect(onChange).toHaveBeenCalledWith({});
});
it("renders no picker for a single-branch union but still shows its fields", () => {
const schema: InspectorFormSchema = {
type: "object",

View file

@ -571,6 +571,36 @@ 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,
@ -588,7 +618,9 @@ export function SchemaForm({
// Which alternative the form is currently showing. Held here because it is a
// 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(0);
const [branchIndex, setBranchIndex] = useState(
() => matchBranchIndex(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
// caller that omits it (the elicitation panels mount fresh) supplies none.
@ -622,8 +654,10 @@ export function SchemaForm({
useValueChange(resetKey, () => {
setEnlargedFields(new Set());
// Which branch is selected belongs to the entity it was chosen for, for the
// same reason enlargement does.
setBranchIndex(0);
// 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);
});
// Stable so a field's reporting effect subscribes once, not per render. The
@ -687,13 +721,21 @@ export function SchemaForm({
if (!nextBranch) return;
setBranchIndex(nextIndex);
const nextProperties = nextBranch.schema.properties ?? {};
// Only what the *base* contributed is carried across — a value the outgoing
// branch declared belongs to that branch's shape, and a name the two
// branches type differently would arrive as the wrong type entirely (a `3`
// typed into branch A's number field landing in branch B's checkbox). A
// field the incoming branch pins to a `const` is likewise not carried: the
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value.
const outgoing = new Set(activeBranch?.declaredFields ?? []);
const carried: Record<string, unknown> = {};
for (const [name, fieldSchema] of Object.entries(nextProperties)) {
// A field the incoming branch pins to a `const` is not carried: the two
// branches of a discriminated union share the discriminator's *name* and
// disagree about its value, so keeping what the outgoing branch put
// there would leave the arguments claiming the shape they no longer have.
if (values[name] !== undefined && fieldSchema.const === undefined) {
if (
values[name] !== undefined &&
fieldSchema.const === undefined &&
!outgoing.has(name)
) {
carried[name] = values[name];
}
}