Skip to content

fix(compilers/openapi)!: type a collection bound beside a $ref - #1302

Merged
OmarAlJarrah merged 10 commits into
mainfrom
fix/openapi-collection-bound-beside-ref
Nov 2, 2026
Merged

OmarAlJarrah merged 10 commits into
mainfrom
fix/openapi-collection-bound-beside-ref

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Nov 1, 2026 •

Copy link
Copy Markdown
Member

Summary

minItems, maxItems and uniqueItems reached ir.Constraints only on the node an array schema lowers to. Written for one use of a shared list, they stayed verbatim in Unmodeled, although the property, parameter, header or alias holding that use has the field and maxLength beside the same $ref already reached it. A consumer validating a field read the typed field and found no bound.

Tags: {type: array, items: {type: string}}
Holder:
  type: object
  properties:
    beside: {$ref: '#/components/schemas/Tags', minItems: 1}
Element:
  type: array
  items: {$ref: '#/components/schemas/Tags', maxItems: 3}
main this branch
Holder.beside constraints: null, unmodeled: {"openapi:minItems": 1} constraints: {minItems: 1}
Element/items constraints: null, unmodeled: {"openapi:maxItems": 3} constraints: {maxItems: 3}
diagnostics an info openapi/degraded-construct at each none

A position that lowers to a reference has no collection node of its own, so what it owns now holds the bound: the alias, or the property, parameter or header carrying it. That covers a $ref site, a collapsed union and an expanded $dynamicRef. The rule is docs/compilers/openapi.md#constraints.

Closes #1287

Decisions a reviewer should weigh

  • This departs from the issue's Direction. It proposed reading the bounds in ConstraintsOf, but that reader also serves nodes. Tried as a mutant, it restates a bound on the carrier of every list property and writes one onto a Model or Scalar that the census also keeps verbatim. So ConstraintsOf is unchanged, and referenceConstraintsOf reads them only where the census asks about a reference: the alias hoistDeclarationHome interns, and a carrier whose position owns no node, the line PreserveRefSiteKeywords already draws. A position owning a List or Tuple keeps its bound there.
  • The census counts a bound as homed only where the reader fills it (referenceHolds). Two stay verbatim, as on main, with their report. uniqueItems: false has no spelling a plain bool can tell from unset, and a non-nil Constraints for it would stop an alias being a decorator. A count past int64 reads as 0 in the parser's model (openapi: an integer bound past 64 bits lowers as 0 #1050), so homing it would replace a faithful record with a false bound; annotation.CountIsExact holds the model to the raw literal, and 1.0 and 1e3 still reach the field.
  • The target is not read. {$ref: Str, minItems: 1} holds the bound as {$ref: Obj, maxLength: 1} holds its own: Constraints says what the position wrote, and reading the target would let declaration order in.
  • A collapsed union moves with the $ref site. #union-null-variant's text listed a collection bound among what no reference holds, a list inherited from the $ref rule it says the collapse follows, and openapi: a oneOf of one schema and null drops its discriminator when it collapses to a nullable reference #595 was about discriminators, so nothing there settled the bound. The test pinning it changed with the rule.

What else the bound now reaches

Reading each consumer of Constraints for what it did with these positions:

  • merge's fold: a redeclared field's bound joins the intersection the fold takes of a value bound (fix(compilers/openapi): intersect a redeclared field's bounds #1307), the larger minItems and the smaller maxItems in either order, with nothing kept or reported, where main keeps the first declaration's verbatim and reports the other. Two redeclared lists whose element aliases differ only in such a bound are judged by it: the narrower wins in either order, and two that meet keep the first and report the other, where main reports them as different types.
  • merge's admission: an alias's bound is judged. main took a default: [[a, b]] over elements bounded by maxItems: 1; it is now refused, and maxItems: 2 still takes it.
  • ir.Decorated: an alias holding a bound is no decorator, which is what docs/emitters/refine/decorators.md says of a constraint. An alias over a list is then the case docs/emitters/refine/scalars.md covers: "its constraints are checked where it is used".
  • pass, irverify and irfacts read no Constraints: grep -rn '\.Constraints\b' ir pass --include='*.go' --exclude='*_test.go' finds only ir.Decorated and the Model-to-MapT form.

Statements read for the old values, by git grep -nE 'MinItems|MaxItems|UniqueItems|minItems|maxItems|uniqueItems' -- docs: the two that assumed them are changed, #constraints and #union-null-variant, and docs/ir/annotations.md gains the carrier's half. The Tuple and emitter sentences read a tuple's own MinItems, and still hold.

Breaking

IRVersion moves to 0.26.0. No key changes shape, so the generations row records the meaning. A consumer pinned to 0.25.0 that reads openapi:minItems, openapi:maxItems or openapi:uniqueItems at these positions no longer finds them, and finds an alias holding such a bound is no longer a decorator.

Limits

Each is stated at the code or rule that has it.

  • An allOf spelling is untouched here. {allOf: [{$ref: Tags}], minItems: 1} lowers to a Model, which keeps the bound verbatim, until fix(compilers/openapi)!: lower allOf by what its conjuncts say #1269 makes it a use of the reference (referenceConstraintsOf's comment, and the rule's Status). A bound on an inline branch, {allOf: [{$ref: Tags}, {minItems: 1}]}, also needs that branch to fold, which neither PR does: openapi: a collection bound on an inline allOf branch beside a $ref is kept verbatim #1312.
  • A collection bound on a node of another shape, an object, a scalar or a position with no type, stays verbatim (#shape-applicator). The golden spec has 112 of these, 102 of them uniqueItems on an object: jq '[.. | objects | select((has("minItems") or has("maxItems") or has("uniqueItems")) and has("type") and .type != "array")] | length' openapi.json.
  • A bound beside a reference is not intersected with what the target writes, which docs/ir/annotations.md#what-a-reference-carries-down leaves on the target's node.

Golden spec

This changes nothing on cloudflare/api-schemas @319dc756 by itself: the direct construct does not occur there, and all 9 uses the issue counts are the allOf spelling.

jq '[.. | objects | select(has("$ref") and (has("minItems") or has("maxItems") or has("uniqueItems")))] | length' openapi.json   # 0
go run ./cmd/morphic compile --verify openapi.json

At 700002e0 against main @7cf3f95f, the IR and diagnostics are identical past the version stamp, and both exit 1, as main does on this spec.

A trial merge of this branch's first head, 7c089590, with #1269 @2af95505 measured those 9 uses:

Other open PRs

Test plan

  • Added the conformance case ref-collection-bounds, covering property, parameter, header, media-type schema, items, component alias and collapsed union, with plain as the control. The order-invariance oracle reaches it through the corpus.
  • Added schema.TestCollectionBound_* for each position, an expanded $dynamicRef among them, with what its census keeps verbatim, each spelling of a count, both sides of the int64 boundary, what stays verbatim, one home per declaration, the other shapes, 3.0, the fold, admission, and a $ref naming the position in both declaration orders; operation.TestParams_CollectionBoundBesideARefReachesTheCarrier and TestParams_ASharedDeclarationLeavesItsListItsBound; annotation.TestCountIsExact.
  • Changed keywords-at-every-outcome, from fix(compilers/openapi): keep a keyword at every schema outcome #1217: the maxItems its carrier writes beside an expanded $dynamicRef now reaches Constraints, so the carrier also writes a false uniqueItems, which stays verbatim and keeps that carrier's census pinned.
  • After merging fix(compilers/openapi): intersect a redeclared field's bounds #1307, TestCollectionBound_ARedeclaredFieldFoldsIt pins the intersection in both orders and TestCollectionBound_RedeclaredListsAreJudgedByTheirElementsBounds the element case. The first reddens with minItems folded to the smaller count, and the second with an element alias holding no bound.
  • TestParams_ASharedDeclarationLeavesItsListItsBound now asserts each List holds its bound; it had passed with every List's bounds dropped.
  • Planted 19 defects at 7c089590 in a throwaway worktree, each killed by a named test: each read skipped or widened, the census homing what the reader left and homing nothing, a false uniqueItems read as held, any count trusted, no alias hoisted, the parameter skipping the carrier read, the issue's ConstraintsOf read, and a truncating read of a count. One first survived, which showed an assertion in ANodeOfAnotherShapeKeepsItVerbatim could never match; it now reads the field.
  • After merging main, planted 13 defects in the readers and the census at 9cb0747c, each killed by a named test, the census at an expanded $dynamicRef switched off among them, which the test's earlier assertions let through.
  • Swept 2,179 specs with sweep.py over testdata and a corpus of real specs, at 700002e0 against main @7cf3f95f: only ref-collection-bounds and keywords-at-every-outcome differ past the version stamp, and the exit codes match (1,659 at 0, 520 at 1).
  • Compiled 960 specs on main @39afce9b and 9cb0747c: 24 positions (component, property, nested and union properties, items, prefixItems, both catch-alls, union and allOf branches, not, then, $defs, dependentSchemas, contentSchema, parameters and headers inline and shared, both bodies, and positions an outside $ref names), 8 spellings (a $ref, one to a nullable list, a oneOf and an anyOf collapsing to one, an expanded $dynamicRef, an allOf over a $ref, and an inline and a nullable list as controls), and 5 markers (minItems, maxItems, a true and a false uniqueItems, and 1e400). Exit codes and errors match, and no marker changes its count of homes. At every reference spelling a valid marker moves from verbatim to Constraints wherever main kept it as an entry of its own; the allOf spelling and both controls are unchanged; no false uniqueItems or 1e400 beside a reference is typed. Under not, then, dependentSchemas and an inline allOf branch the marker is held raw in that keyword's verbatim entry, alike on both.
  • At 7c089590, diffed two declaration orders of one document holding every carrier, shared parameters and headers, and an outside $ref naming each position: no difference in the document or the diagnostics.
  • The harness's oracles, order invariance among them, pass on both changed cases at 9cb0747c.
  • CI runs the gate on this head.

A collection bound written for one use of a shared list stayed verbatim in
Unmodeled, though ir.Constraints holds it on a carrier and on an alias.
ConstraintsOf leaves the three keywords to the collection nodes, and a
position that lowers to a reference has none.

minItems, maxItems and a true uniqueItems beside a $ref, a collapsed union or
an expanded $dynamicRef now reach the Constraints of the property, parameter,
header or alias holding that use, as maxLength beside the same $ref does. The
read stays out of ConstraintsOf, which also serves nodes: there it would
restate every list property's bound on its carrier and write one onto a Model
or Scalar that the census keeps verbatim. A position owning its node keeps its
bound there.

The census counts a bound as homed only where the reader fills it, so two stay
verbatim as they were: a false uniqueItems, which a plain bool cannot tell
from unset and which would stop an alias being a decorator, and a count past
int64, which the parser reads as 0.

Breaking: IRVersion moves to 0.24.0. No key changes, but a consumer pinned to
0.23.0 that reads openapi:minItems, openapi:maxItems or openapi:uniqueItems at
these positions no longer finds them, and an alias holding such a bound is no
longer a decorator.
A bound beside a reference is read from the parser's model and held to its raw
node, so a test on plain literals says nothing of the spellings the two read
apart. Pin a YAML alias, a merge key, hex, octal and separated integers, and
both sides of the int64 boundary, one of which a truncating read would hold as
a false bound. Pin too that a parameter and a header reached through a $ref
leave a bound to the List they lowered to, where a carrier's pointer could
differ from the schema's.

CountIsExact now asks the model first, so a position writing no bound does no
node lookup.
Two collection-bound tests named a local after the package they import, and one table branched on its row. Rename the local and hold the fold's conflicts to a count, so the table stays flat.
The constraints rule said a collection bound beside a reference that
Constraints cannot state is kept verbatim. That holds at a $ref site and a
collapsed union, which run the keyword census, but an expanded $dynamicRef
runs none (#848), so a false uniqueItems or a count past int64 beside one is
dropped there. The 0.24.0 history entry likewise said every such position had
kept its bound verbatim, where an expanded $dynamicRef had dropped it.

Scope the sentence to the positions it is true of, name the drop in the rule's
Status, and name #1312 beside #1269 for a bound on an inline allOf branch, which
needs that branch to fold as well.
main took IRVersion 0.24.0 for #1219, so this change moves to 0.25.0,
with its own generations row and history entry.

#1217 made an expanded $dynamicRef run the keyword census. That census
asks keywordHome at no node, which now homes a collection bound the
reader fills, so the keywords-at-every-outcome carrier holds its maxItems
in Constraints. The case now writes a false uniqueItems beside it too,
which stays verbatim, so the census at that carrier is still pinned. The
rule's Status drops the items #1217 closed and the clause saying a bound
beside an expanded $dynamicRef is dropped.
With main's census at an expanded $dynamicRef, a collection bound the
reader cannot state there, a count past int64 or a false uniqueItems, is
kept verbatim and reported, as beside a $ref, where it had been dropped.
The test said it was dropped and asserted only that no Constraints was
written, so it passed whether the census ran or not. It now asserts the
kept keywords and their report, and the rule's sentence on what stays
verbatim names every position lowering to a reference.
#1226 took IRVersion 0.25.0 for a shape change, so this change moves to
0.26.0 on that shape, with its own generations row and history entry.
The new case's golden gains the unresolved auth namespace every document
now declares.
#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.
An element alias over a $ref now holds the bound written beside it, so
the allOf fold judges two redeclared lists alike in all but that bound
by it: the narrower wins in either order, and two whose bounds meet
overlap, keeping the first and reporting the other. Before, each alias
kept its bound verbatim and the two lists were reported as different
types.
TestParams_ASharedDeclarationLeavesItsListItsBound checked that neither
carrier restated the bound and that each type was a List, but not that
the List held the bound, so it passed with every List's bounds dropped.
It now asserts the bound on each List.
@OmarAlJarrah
OmarAlJarrah merged commit d44dc4e into main Nov 2, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-collection-bound-beside-ref branch November 2, 2026 01:48
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
#1302 took 0.26.0 for a change of meaning, with the shape of 0.25.0,
so this change moves to 0.27.0 and keeps its shape hash. Its history
entry now names 0.26.0 as the version an older consumer is pinned to.
OmarAlJarrah added a commit that referenced this pull request Nov 2, 2026
Brings in #1302 and #1304.

- #1302 took IRVersion 0.26.0, so this branch's bump moves to 0.27.0,
  its row keeping 0.26.0's shape.
- #1302 reads a collection bound into what a position lowered to a
  reference owns, through CarrierConstraintsOf and
  referenceConstraintsOf. A refinement lowers to a reference, so each
  takes the voices ConstraintsOf takes here, and a bound beside an
  allOf refining a $ref is typed on the alias or the carrier, which
  TestCollectionBound_BesideARefinementReachesWhatThePositionOwns pins.
  A bound on its branch still stays with the branch (#1312), so
  referenceConstraintsOf's comment and the constraints rule's status,
  which said such an allOf lowers to a Model, say that instead.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

openapi: a collection bound beside a $ref stays in Unmodeled though Constraints holds it

1 participant