Repository navigation
fix(compilers/openapi): intersect a redeclared field's bounds - #1307
Merged
Merged
Conversation
Every allOf branch must hold, so a field two branches declare carries what both say. The fold kept the first declaration's bound and reported the other, so the field was looser than the spec and branch order chose its value. A bound on a type's own node never reached the fold, and a default was never held to a bound another declaration wrote on the property. Each ordered keyword now takes its tightest value, and multipleOf the step whose multiples are those both admit. A keyword with no order, pattern among them, stays kept and reported. Two nodes alike but for their bounds are judged by those bounds: the one saying all the other does is narrower, and where neither does the two meet, and the field takes a node minted in the composed namespace holding both. A bound the field implies is no loss, so it draws no report and no Unmodeled entry. A value is held to each bound another declaration brought. A bound a declaration states itself at the value the field keeps stays its own to answer for. A field keeps one provenance pointer: a list of declarations would not say which wrote each keyword, and no consumer reads one. The rule's claim that every branch stays reconstructible was false for a folded declaration and now says what holds. Closes #10 Closes #1288 Closes #1289
Two planted defects survived. Minting a node beside a composition passed because the test's stub lets a second mint overwrite the first, where Register refuses it, so the test now counts mints. Reading any related pair as alike passed because no case had a declaration narrower by its element write a value, so one now does.
A planted defect reading every declaration into a minted node survived, and it would put a dropped declaration's bound on the field, so a case now has one drop beside two that meet. No row had a narrower list holding detail against a broader one, so two sides of holdsDetail went unread on any verdict. The check that any declaration meets the winner is gone. With none, the only bounds read are the winner's own, which leaves the winner's node, so it skipped work on a rare path and said nothing.
A tuple has a member per position where every other node has one, and a $ref to a sibling position adds width at no cost in document size, so comparing nested tuples cost width^depth: a 14 KB document ran past 40 seconds where main takes 0.03. A tuple another type holds is no longer compared, as it was not before tuples were judged, which leaves the work linear in a tuple's width. A read-counting test holds that. One keyword a position writes on its node and its property was reported twice where the two folds kept it from different declarations, since the check compared whole messages. It now compares what disagrees.
Extract the two types takes compares, name the bounds detailRelation holds aside, and give a test closure its own subtest's t rather than the parent's.
This was referenced Nov 1, 2026
Say plainly when a folded declaration is reported, and rewrap what the rule's edits left past the document's width.
A value's declaration answers for it where its type is the field's but for bounds, but that check ran on a judge holding no provenance, so two lists in another document read as declared apart and a default on one was refused. The judge now holds where each declaration was written. The multiple of two steps now goes through NewBigVal, the constructor every other bound takes, which also refuses one past the range. The rule and the judgment's comment said a pair gets one verdict as a field and as members, which tuples and meets make false, and each now says so. The rule names the tests this work added since, and the test of flipped holds the new verdict.
This was referenced Nov 2, 2026
Two lists, maps or tuples were compared by their bounds, encoding, documentation, examples and Unmodeled, and by nothing else a node holds. Folding containers by their bounds turned that gap into silent loss: a list writing minItems beside one writing maxItems and deprecated: true minted a node from whichever was declared first, so the deprecation survived in one branch order only, and a narrower list kept alone dropped a broader one's XML hints. Neither drew a diagnostic, where both were reported and kept while differing bounds made an overlap. A container is now compared on its whole TypeCommon but where it was declared, as sameNode already compares a Scalar, so a node kept for two loses nothing either holds. Containers that differ in such a fact are an overlap: the second declaration is reported and kept verbatim, in either order.
The fold gives the field the type that holds the most declarations' types, and a meet counts as holding. The rule still described the count as the same or narrower, and widestCone's comment still said a type holding every other is the intersection, though two types that meet hold each other and neither is: the node minted from the winner is. Declaring a field as an array of numbers with minItems, one with maxItems and two arrays of integers shows the difference: the field is the minted array of numbers holding both bounds, and the integer declarations are reported, where the old sentence would pick the integers.
The unresolved auth namespace main added lists in every golden's ID spaces, so allof-redeclared-bounds.golden.json gains it here.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
Brings in #1226 and #1307. - #1226 took IRVersion 0.25.0, so this branch's bump moves to 0.26.0, its row keeping 0.25.0's shape. Its new unresolved auth namespace reaches allof-refinement.golden.json's idSpaces, and lowering.Ctx lost WithAuth, so TestRemembered_IsReadOnceAcrossCopies derives its copy with InScope. - #1307 made the constraint merge a meet: each ordered keyword takes the tighter value. Its body is kept under the exported name this branch gives it, merge.Constraints, and the tests named after the old name follow it. Voices that write one bound alike still meet to that bound; bounds that differ still keep their branches, which the next commit changes.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
#1307 made the allOf fold intersect a redeclared field's bounds, the collection bounds among them. A bound this change puts on a carrier therefore folds the same way: two declarations writing minItems 1 and 2 give 2 in either order, with nothing kept and nothing reported, where the fold had kept the first and reported the other. The test pinning the fold follows.
OmarAlJarrah
added a commit
that referenced
this pull request
Nov 2, 2026
Two conjuncts writing one value bound differently disagreed, so each
branch stayed whole under Unmodeled and the position held neither:
{allOf: [{$ref: Str}, {maxLength: 10}, {maxLength: 5, description: d}]}
kept both branches, the description with them. #1307 made a
redeclared field's bounds meet, each taking what the declarations say
together, and merge.Constraints is the meet ConstraintsOf folds voices
with, so a position's conjuncts now agree the same way: the example
holds maxLength 5 and its description, and multipleOf 4 beside 6 holds
12, in either order.
The agreement is a set's answer, read once per schema: a bound agrees
where every writer's reading of it states a value and the values meet
(merge.Meets). Two patterns have no one value, and a 3.0 exclusive
modifier with no bound beside it fills no field, so each still
disagrees with a writer spelling it otherwise. The allof-inline-residue
rule says so, and its status names the one case left, a collection
bound on a branch (#1312), in place of #10.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Every
allOfbranch must hold, so a field two branches declare should carry the intersection of what they say. The fold kept the first declaration's bound and reported the other, so the field was looser than the spec, and which value it held followed branch order.xmaxLength: 20,maxLength: 1020, with a warning10, in both ordersmultipleOf: 4,multipleOf: 64, with a warning12, in both ordersmaxItems: 5maxItemsunderUnmodeledminItems: 1, one withmaxItems: 5maxItemsunderUnmodeleddefault: hello,maxLength: 3Three things change, all in
compilers/openapi/internal/merge, with the rule indocs/compilers/openapi.md#allof-redeclared-property.Bounds on a property take their intersection. Each ordered keyword takes its tightest value, by magnitude, so no order decides it.
multipleOftakes whichever step is a multiple of the other, and otherwise their least common multiple, a number no declaration wrote, computed exactly and without building a wide power of ten.pattern,patternMessage,precisionandscalehave no order and no one value saying two of them, so they stay kept and reported. A bound the field implies draws no report and noUnmodeledentry.Bounds on a node reach the field. Two
Scalars, lists, maps or tuples alike in all but their bounds are now judged by those bounds. The one saying all the other does is narrower and wins in either order. Where neither does, a new verdict says the two meet, and the field's type is a node minted in thecomposednamespace, holding the winner's detail and every bound. Alike means in every fact a node holds but where it was declared, deprecation and XML hints included, so a node kept or minted for two loses nothing either holds; two containers differing in such a fact stay an overlap, reported and kept.A value is held to the bounds others brought. A default or example is judged against each bound the field keeps that another declaration contributed, on the property or on a node. A bound the declaration states itself at the value the field keeps stays its own to answer for, so
{maxLength: 3, default: hello}written once lowers as it did.Decisions a reviewer should weigh
allof-redeclared-propertykept a losing declaration so that a release changing it still shows in the document. A looser bound loses nothing: a release changing it changes no value the field admits, which is exactly why a broader type already draws no report. Keeping it would list every narrowing as a loss.multipleOfcomputes a value no declaration wrote, rather than keeping one and reporting the other. Keeping one leaves a validator accepting values the spec refuses, with branch order choosing which. The multiple's value set is exact. A pair past 1,024 digits, or a multiple past what aBigValholds, is kept and reported as a keyword with no order is.BothBounds, rather than writing the difference toProperty.Constraints. Bounds conjoin across a property and its node (ir.Constraints), so that would be sound, but no property holds an item bound today: a list's and a tuple's bounds stand on their node alone, and a$refbesideminItemskeeps it verbatim for having no home. It would also leave the field's type following branch order. A minted node gives one home, in every order, and is the step openapi: an allOf narrowing between two enums or two lists is resolved by branch order #1042 needs.Propertywould say which contributed and not which wrote each keyword, and no consumer reads either. Adding it later is an ordinary versioned change. The sentence "every branch stays reconstructible from the composition" was false for a folded declaration and now says what holds, and the decision is in the rule.IRVersionbump. No key changes shape or meaning, and every member filled here is already filled for other inputs. APropertynaming a node incomposedis new for a non-model, so the sentences about that namespace were read andids.go's were corrected;docs/iranddocs/emittersstate nothing that assumes a composed node is a model.Limits, each stated in the rule
items: {minLength: 1}againstitems: {maxLength: 5}, stays reported as an overlap: a fold builds a node for a field's own type and none for what it holds. A member that narrows does narrow the container.$comment,$anchor,$dynamicAnchorand$defs, kept nowhere by design (vocabulary-coverage), andunevaluatedPropertieson a non-object (openapi: unevaluatedProperties false on a position lowering to no model is dropped #1283). Onmainsuch a keyword survived in one branch order only, inside the losing declaration kept whole, and a single schema writing it drops it on both builds. Once openapi: unevaluatedProperties false on a position lowering to no model is dropped #1283 keeps it on the node, the fold keeps it too, since a node holding something the other lacks is no longer folded.minLength: 6besidemaxLength: 5. That lowers as declared, as a single schema writing both does, and draws no report. pass: report bounds that admit no value #1300 proposes the report.Other open PRs
Not stacked, and
mainis merged in at172a9f03, which holds #1217, #1219 and #1226. #1226 adds an ID namespace every golden lists, so the merge regeneratedallof-redeclared-bounds.golden.jsonby that one entry. #1269 conflicts inconstraints.go,fold.go,conflict_internal_test.goandmatrix.md, and needs more than a textual merge: it lowers anallOfat one position and keeps two differing bounds, citing #10, so whichever lands second gives that position the same intersection, throughmergeConstraints. #1302, #1278, #1229 and #1227 conflict withmainitself, and with this branch in no file beyond those. #1199, #1233, #1235, #1277, #1301, #1304, #1311 and #1313 merge cleanly.Three commit subjects pass the cap with their number, the third at 76 characters, the seventh at 74 and the tenth at 75 (merges aside). They are pushed, so they stay, and the title is what lands.
Golden spec
cloudflare/api-schemas@319dc756,main@172a9f03against the head after merging it: IR and diagnostics byte-identical on both builds, each exiting 1 under--verify, andgrep -c 'declarations of field'is 0 on each. Neither issue touches it, as their priorities say.Test plan
--verifyexits 0.--verify. A validator written from JSON Schema's definitions judged an instance pool against the source declarations and against what a reader conjoins from the property and its node chain. No instance the source admits is rejected. None it refuses is admitted unless reported and kept, and all 368 such reports are two patterns, against 11,402 diagnostics onmain. Permutations of a set agree on what they admit unless a tie is reported. A planted defect reading a meet as a narrowing drew silent loosening and 38 order-dependent sets, so the oracle can fail.cmd/morphic-harnesspasses over the same spec.nullableand booleanexclusiveMinimum/exclusiveMaximum; 3,095 models where every other declaration is a$refto a component; and 2,996 adding objects with property bounds and lists of formatted items. None rejects a value the source admits, none loosens a bound without a report, and the field admits null exactly where every declaration does.deprecatedorxmlon the declaration that did not win, a field kept or minted the other node and dropped it silently, the next item but one.$refinto it: a field there mints its node in that document'scomposednamespace. This found that the check letting a declaration answer for its own value ran without knowing where each declaration was written, so a default on a list in another document was refused while the same declaration in the root document was adopted. Fixed in the seventh commit, withTestFold_AValueOnAListInAnotherDocumentIsJudgedAsItsOwn.deprecatedorxmlit writes, in both orders: until the last commit the field took the narrower node, losing the broader one's fact in both orders, or a node minted from the first, losing it in one, and neither drew a diagnostic. Containers are now compared asScalars are, on everything but where each was declared, so the pair is an overlap, reported and kept in either order, as onmain.TestAllOf_ANodeHoldingMoreThanBoundsIsKeptInEitherOrderreddens on each container row with the earlier comparison; its scalar row is the control. The fix changes no exit code, IR or diagnostic over the 2,177 specs oftestdataand the reference corpus, under--verify.TestAllOf_ABoundTakesItsTightestValueInEitherOrder,TestAllOf_ANodesBoundsReachTheFieldInEitherOrderand theallof-redeclared-boundscase, since the order oracle never swaps a sequence. The merge's own tests fold three declarations in every order.sweep.pyagainstmainovertestdataand the reference corpus, with--verify, 2,172 specs: exit codes identical (1,651 zero, 521 one), and 2 differ. One is the new case. The other is ogen'sallOf.yml, whoseobjectsWithConflictingArrayPropertyis theminItemsbesidemaxItemsshape: it keptmaxItems: 5only underUnmodeled, and now holds both on one list.--verifyand through the harness: a distributed union, whose variants mint one node; a wrapper with a$refinto its branch; each nullability; two components; a discriminator's property; hoisted elements; nested bounds.main, 20 more planted afresh at the bounds, step, judgment and value checks: 20 killed.$refto its sibling, ran past 40 seconds wheremaintakes 0.03, and it now takes 0.06.TestTypeJudge_ATupleOfTuplesCostsItsWidthNotItsWidthToTheDepthcounts the reads. Bounds cost a constant per pair: with every field declared 64 times, the most a fold judges, 200 fields take 2.1 s of CPU onmainand 7.7 s here, and 400 take 4.2 s and 14.9 s, so it stays linear in the document. Golden draws no redeclaration report, and takes 14.5 s of CPU on each build.go test ./...andgo vet ./...at0f8c5414,golangci-lintv2.14.0 at 0 issues, and 100% of statements onmerge,schema,idsandopenapitest. At the head, after mergingmainat172a9f03:go build ./...,go vet ./...andgo test ./..., and 100% of statements onmerge,schema,idsandopenapitest. The full gate is CI's.go test ./cmd/morphic-census: no fact gained or lost a witness.Closes #10
Closes #1288
Closes #1289