Skip to content

fix(compilers/openapi): lower the fields beside a path item $ref - #591

Open
fuad-daoud wants to merge 6 commits into
mainfrom
relevo/path-item-ref
Open

fuad-daoud wants to merge 6 commits into
mainfrom
relevo/path-item-ref

Conversation

@fuad-daoud

@fuad-daoud fuad-daoud commented Sep 29, 2026 •

Copy link
Copy Markdown
Collaborator

Summary

A Path Item Object that is a $ref may carry fields of its own — OpenAPI 3.0 and 3.1 both describe a field that "appears both in the defined object and the referenced object", and leave only a same-name collision undefined. The library's reference wrapper erases every sibling: GetCore().Object is nil on the $ref branch, so whole operations (with their parameters, bodies, responses and callbacks), the item's summary/description, servers, item parameters, x-* and undeclared keys reached the IR in no form at all, with one unrelated remount warning as the only diagnostic.

The issue's document now lowers putA at op/openapi/paths/~1a/put with provenance /paths/~1a/put, its summary under openapi:pathItemSummary at /paths/~1a/summary, and getB still mounts at both /a and /b.

Mechanism

  • Read. The use-site pairs are read raw through nodeview (MappingPairs follows an alias and expands a << merge key exactly as the resolver does), with $ref removed. Nothing is read when the source wrote nothing beside the reference, so a sibling-free item keeps the path it had.
  • Model. The surviving pairs are re-keyed into a synthesized mapping — synthesized keys, the document's own value nodes, so RawChildNode and line/column survive — and unmarshalled into a *soa.PathItem, because an operation can be lowered only from a *soa.Operation.
  • Resolve. The subtree's references are resolved at the mount, from the *soa.ReferencedPathItem wrapper (the document-level walk does not descend into a $ref, and a $ref'd callback's expressions are reached only from the mount), under the compile's own external-refs policy, threaded onto lowering.Ctx by WithExternalRefs. A failure is reported as openapi/unresolved-ref at the use-site pointer.
  • Lower. One seam (mountOperation/mountedOperations) is now the only place a path-item operation is lowered, so the paths, webhooks and callbacks routes and both sides of a $ref reach it alike. A sibling operation carries its own mount and declaration pointer (/paths/~1a/put), which is what keeps a repeated operationId a conflict (openapi/conflicting-operation-id) rather than a remount.
  • Collide. A construct both declarations write is reported once per construct under openapi/path-item-ref-collision (warning), sited at the use site's key and naming the referent's; the use site's declaration is the one lowered at that mount, the precedence the compiler already applies to a $ref-adjacent schema sibling.

No IR field, ID grammar or new ID namespace. #383, #384/#395, #396 and #414 are deliberately untouched.

Deviations worth a reviewer's eye

  • mountOperation/mountedOperations were introduced so the three routes share one operation-lowering path rather than three near-copies; internal/archtest/recursion_test.go gains them in the callback recursion set.
  • mergeParameters is now reached only from its own internal test (production callers use mergeRefParams); whether the two names should collapse is a reviewer's call.
  • The rebuilt sibling model's unmarshaller findings are discarded: the loader never modelled these nodes, and this compiler reports what it cannot carry through its own lowerings.
  • Out of scope, as recorded on the issue: a chained $ref whose intermediate item itself carries siblings, an external target document holding the siblings, and an alias bringing $ref+siblings to a second mount (existing alias/remount semantics, openapi: a path item mounted twice by a YAML alias is refused where the $ref form only warns #502).

Merged with main

main gained cross-document scoping (#763), group IDs (#801) and the doc-comment cap after this branch was cut. The merge (33c1f98) combines them:

  • Mounts lower under Within. Each route keeps main's lowering.Within for the referenced path item, and reads what is written beside the $ref in the use site's own context. siblingRefMount, the use-side operations and their unmounted remainder take that context, and applyPathItem applies each side's item constructs in its own (pathItemMount.refIn/useIn).
  • Per-parameter scope. One operation lowers both sides' item parameters, so each entry combinedPathParams builds carries the scope it is read in (sourcedParam.in), which lowerParameters applies through Ctx.InScope. Without it, across documents, the referent's parameter on a use-side operation bound the source's same-named schema, and a use-site parameter on the referent's operation lost its declaration's pointer. TestPathItemRefSiblings_EachSideReadsInItsOwnDocument pins both, along with the use-side operation's own references. Removing either scope, or lowering the use side in the referent's scope, reddens it (planted on the paths route).
  • Groups are keyed by GroupID. The new conformance golden gains main's irVersion, idSpaces and group ID, and nothing else.
  • Limitation. With external references allowed, the sibling subtree's references are read by the library's own file access, not the loader's reader, so MaxSourceBytes and MaxSourceNodes do not apply to them. This is stated on resolveSiblingRefs and tracked in openapi: a $ref beside a path item's $ref is read outside the external-document budgets #818. The default compile, with external references refused, is unaffected.

The first make gate line in the test plan predates this merge; the one after it is the gate on the merged head.

Test plan

  • Unit tests per route (paths, webhooks, callbacks), the issue reproducer, item constructs on every mounted operation, $ref'd parameter/request-body/response/callback inside the sibling subtree, the unresolved report, the unmounted case on all three routes.
  • The webhook and callback route tests assert the full item-level construct set (summary, servers, x-*, undeclared key, item parameter, with the pointers under the route's own use site), through one shared assertion helper the paths route also uses, so "the same set" is true by construction.
  • Sibling-subtree reference kinds: a $ref'd parameter, request body, response, callback, header and example each assert onto the lowered IR. A link is asserted as the preserved openapi:links entry, with a dedicated dangling-link case proving the reference is genuinely walked and resolved — no Link Object lowers to a node of its own in this compiler (GitHub openapi: response extensions dropped everywhere, links dropped on error responses #275, pinned by TestResponses_LinksPreserved), so a typed assertion is impossible today; if a later change gives it one, this fixture should gain it.
  • An end-to-end external-ref pair (resolve_main_path_item_sibling.yaml + resolve_target_path_item_sibling.yaml): a parameter written by an operation beside the $ref references a second document. Compiled with the default options it reports openapi/unresolved-ref at /paths/~1a, naming the target and the seam ("parameter written beside the path item's $ref … could not be resolved: external reference not allowed") — reported, not dropped; compiled with --opt allow-external-refs=true it resolves and the parameter reaches the IR. The main fixture joins the harness corpus's known-invalid list with its reason. by CLI, both directions.
  • TestSiblingRefMatcher_CoversTheLibraryVocabulary derives the reference-kind fields from soa.Matcher by reflection and requires the matcher to cover every one.
  • Collision: one warning per construct with both pointers, use site wins, irverify clean, no duplicate OpID, and a two-order invariance case.
  • operationId: a sibling repeating the referent's id is openapi/conflicting-operation-id, never a remount.
  • Conformance case path-item-ref-siblings.yaml + golden + table entry, with a planted deletion proven to redden it; testdata/openapi/path_item_ref_siblings.yaml swept by go run ./cmd/morphic-harness (no-panic → no error diagnostic → irverify → round-trip → determinism → order-invariance).
  • make gate green on the branch before the merge: all 7751 statements covered, golangci-lint 0 issues, nolint gate, fuzz and bench-smoke pass; no existing golden changed.
  • make gate green on b51cd8f, the merged head with the limitation note: 100% statement coverage, golangci-lint 0 issues, fuzz and bench-smoke pass.
  • Byte-identity for a sibling-free item: the branch binary and a main binary compile testdata/conformance/openapi/path-item-operations.yaml to identical bytes (both sha256 0fe3ccdaf3a39123242c29926fadb51dfc4f2c4377e9adb9433fb0d45ae6cf2c, 5098 bytes), so "no golden changed" is an observation rather than only an assertion.
  • A final whitespace commit ends the new fixtures with a newline (the convention every other fixture follows) and regenerates the conformance golden's one-line source hash accordingly.

Closes #577.

@fuad-daoud fuad-daoud self-assigned this Sep 29, 2026
@fuad-daoud fuad-daoud changed the title fix(compilers/openapi): lower the fields written beside a path item $ref (#577) fix(compilers/openapi): lower the fields written beside a path item $ref Sep 30, 2026
@fuad-daoud fuad-daoud changed the title fix(compilers/openapi): lower the fields written beside a path item $ref fix(compilers/openapi): lower the fields beside a path item $ref Sep 30, 2026
relevo and others added 4 commits September 30, 2026 17:58
… $ref (#577)

A Path Item Object that is a $ref may carry sibling fields, and the library's
reference wrapper erases every one of them: operations, item parameters,
servers, x-* and undeclared keys reached the IR in no form at all, with no
diagnostic. They are now read raw through nodeview, modelled so their
operations can be lowered, their references resolved at the mount under the
compile's own external-reference policy, and their item-level constructs
carried onto every operation mounted at that path. A construct both sides
declare is reported under openapi/path-item-ref-collision, sited at the use
site's key and naming the referent's, and the use site's declaration wins.
…rpus and the harness (#577)

Adds a capability-conformance case for the fields a path item writes beside
its $ref, with a planted deletion proven to redden it, the operationId and
two-order collision cases, and a fixture the harness sweeps through every
oracle.
…, extra routes and external policy (#577)

Item 1: the reference-kind fixture gains a $ref'd header, link and example
declared in components and reached from an operation written beside the $ref,
each asserted on the IR; a companion case pins that a link reference in the
sibling subtree is resolved (a Link Object has no IR home, so the map reaches
the IR verbatim and the diagnostic is the resolution's only evidence).

Item 2: the webhook and callback route tests assert the full item-level
construct set the paths route asserts, through one shared helper, with the
pointers under each route's own use site.

Item 3: a fixture pair whose sibling reference leaves the document
(resolve_main_path_item_sibling.yaml -> resolve_target_path_item_sibling.yaml),
compiled with and without allow-external-refs: the target lands with the
option, and the unresolved reference is reported without it. The main fixture
joins harness knownInvalid with its reason.

Item 4 (report only): the branch binary and a main binary lower
testdata/conformance/openapi/path-item-operations.yaml byte-identically.
@fuad-daoud

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (07576ab) at 2026-09-30. The first CI run failed with a golden mismatch on the new fixture: #551 landed after this branch was cut and makes a shared primitive carry provenance.source: -1 (NoSource) where the golden said 0. The new golden's one primitive line was regenerated; go test ./... is green on the rebased branch and the gate is re-running. No production code changed in this rebase.

The sibling lowering now runs under main's cross-document scoping. Each
route keeps main's Within for the referenced path item and reads the
fields beside the $ref in the use site's own context: siblingRefMount,
the use-side operations and their unmounted remainder take that context,
and applyPathItem applies each side's item constructs in its own.
Because one operation lowers both sides' item parameters, each entry
combinedPathParams pairs carries the scope it is read in, which
lowerParameters takes up; without it a referent's parameter on a
use-side operation bound the source's same-named schema, and a use-site
parameter on the referent's operation lost its declaration's pointer.
TestPathItemRefSiblings_EachSideReadsInItsOwnDocument pins both, and
the use-side operation's own references.

Groups are keyed by main's GroupID, so the webhook grouping names
ids.WebhookGroup. WithExternalRefs stays beside WithMappingTargets: the
sibling subtree is modelled after loading, so the loader's resolution
never reaches it and its references are resolved under the same policy.
corpus_test takes main's inline reasons with this branch's entry added,
the doc comments the cap now covers are shortened, and the new
conformance golden gains main's irVersion, idSpaces and group ID.
resolveSiblingRefs resolves with bare soa.ResolveOptions, so with external references allowed the library opens those documents itself and MaxSourceBytes and MaxSourceNodes are never charged. The doc comment now says so and points at #818, which tracks handing the loader's reader to lowering.
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: fields written beside a path item's $ref are dropped in silence

1 participant