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:
parent
d8e9e502be
commit
7e4af5ed34
1 changed files with 188 additions and 51 deletions
|
|
@ -229,9 +229,20 @@ function mergeProperty(
|
|||
* - **It is a `$ref`.** The referent is not resolved by this module, so its
|
||||
* constraints are unknown rather than absent.
|
||||
*/
|
||||
const MERGEABLE_KEYWORDS = new Set(["type", "properties", "required"]);
|
||||
|
||||
function isFlattenable(member: unknown): boolean {
|
||||
const branch = toBranch(member);
|
||||
return branch !== null && branch.$ref === undefined;
|
||||
if (branch === null || !admitsObject(branch)) return false;
|
||||
// `mergeBranch` applies `properties` and `required` and nothing else, so a
|
||||
// member stating anything further would have that constraint erased along
|
||||
// with the keyword — a nested `anyOf`, a `not`, an `additionalProperties`,
|
||||
// a `$ref` whose referent is not resolved here. Only the keywords the merge
|
||||
// actually carries, plus annotations that constrain nothing, are accepted.
|
||||
return Object.keys(branch).every(
|
||||
(keyword) =>
|
||||
MERGEABLE_KEYWORDS.has(keyword) || ANNOTATION_KEYWORDS.has(keyword),
|
||||
);
|
||||
}
|
||||
|
||||
/** Whether any property declaration of `branch` conflicts with the base's. */
|
||||
|
|
@ -391,20 +402,21 @@ export function resolveRootUnion<T extends RootUnionSchema>(
|
|||
// branch would otherwise be merged against a base whose constraints are not
|
||||
// all known.
|
||||
const allOfMembers = schema.allOf ?? [];
|
||||
const flattenable =
|
||||
allOfMembers.every(isFlattenable) &&
|
||||
allOfMembers.every(
|
||||
(member) =>
|
||||
!conflictsWithBase(schema, toBranch(member) as RootUnionSchema),
|
||||
);
|
||||
if (!flattenable) {
|
||||
return { base: schema as ResolvedSchema<T>, branches: [] };
|
||||
let merged = schema as ResolvedSchema<T>;
|
||||
for (const member of allOfMembers) {
|
||||
const branch = toBranch(member);
|
||||
// Each member is checked against what has been merged **so far**, not
|
||||
// against the original root: two members declaring `x.minimum` as 10 and 0
|
||||
// agree with a root that declares neither, while contradicting each other.
|
||||
if (
|
||||
branch === null ||
|
||||
!isFlattenable(member) ||
|
||||
conflictsWithBase(merged, branch)
|
||||
) {
|
||||
return { base: schema as ResolvedSchema<T>, branches: [] };
|
||||
}
|
||||
merged = mergeBranch(merged, branch);
|
||||
}
|
||||
|
||||
const merged = allOfMembers.reduce<ResolvedSchema<T>>(
|
||||
(acc, member) => mergeBranch(acc, toBranch(member) as RootUnionSchema),
|
||||
schema as ResolvedSchema<T>,
|
||||
);
|
||||
const base = withoutComposition(merged);
|
||||
|
||||
if (schema.oneOf !== undefined && schema.anyOf !== undefined) {
|
||||
|
|
@ -438,3 +450,38 @@ export function resolveRootUnion<T extends RootUnionSchema>(
|
|||
})),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The index of 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. Shared because more than one caller has
|
||||
* to reach the same answer: the web form opens its picker on that branch, and
|
||||
* the defaults seeded before the values are overlaid must belong to the same
|
||||
* one, or the arguments carry a shape the picker is not showing.
|
||||
*/
|
||||
export function selectBranchIndex<T extends RootUnionSchema>(
|
||||
branches: RootUnionBranch<T>[],
|
||||
values: Record<string, unknown>,
|
||||
): number | null {
|
||||
const matches: number[] = [];
|
||||
branches.forEach((branch, index) => {
|
||||
const pinned = Object.entries(propertiesOf(branch.schema) ?? {})
|
||||
.map(
|
||||
([name, schema]) =>
|
||||
[
|
||||
name,
|
||||
(toBranch(schema) as { const?: unknown } | null)?.const,
|
||||
] as const,
|
||||
)
|
||||
.filter(([, constValue]) => constValue !== undefined);
|
||||
if (
|
||||
pinned.length > 0 &&
|
||||
pinned.every(([name, constValue]) => values[name] === constValue)
|
||||
) {
|
||||
matches.push(index);
|
||||
}
|
||||
});
|
||||
return matches.length === 1 ? matches[0] : null;
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue