Skip to content

openapi: resolve or drop reference targets instead of emitting dangling IR IDs - #22

Merged
OmarAlJarrah merged 4 commits into
mainfrom
fix/openapi-dangling-refs
Jul 21, 2026
Merged

openapi: resolve or drop reference targets instead of emitting dangling IR IDs#22
OmarAlJarrah merged 4 commits into
mainfrom
fix/openapi-dangling-refs

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Jul 21, 2026

Copy link
Copy Markdown
Member

Summary

Several reference-like constructs were lowered into IR IDs without checking that the target resolved, and without emitting a diagnostic — leaving the IR with a type or auth ID that pointed at nothing. This unifies every reference-lowering path on one rule: never write a type/auth ID into the IR unless the target interns, mirroring how normal schema-position $refs already emit openapi/unresolved-ref.

  • Discriminator mapping (oneOf/anyOf unions and allOf model bases): a mapping value is resolved only to an already-interned schema — a bare name (including one containing /), a $ref to a declared component, or an already-interned node. Unresolvable or external targets are dropped with an openapi/unresolved-ref diagnostic; an all-dropped mapping collapses to nil for a clean round-trip.
  • Sub-schema $refs (e.g. #/components/schemas/Foo/properties/bar, a not/validation-only branch, a map-value sub-schema): the referenced sub-schema is now interned/hoisted under its pointer-derived ID so the ref resolves, instead of minting an un-backed ID.
  • Same-file vs external: a $ref whose document part is the compiled file's own name is treated as internal and resolved; a document part that carries its own directory is matched in full rather than by basename, so a cross-directory reference is never mistaken for a self-reference; genuinely external cross-file targets are dropped with a diagnostic (Milestone 1 does not intern cross-file).
  • Security requirements: a requirement naming a scheme not declared under components.securitySchemes is dropped with a diagnostic rather than writing a dangling AuthID.
  • Component reference IDs derive from the canonical name. A reference to a top-level component computes its ID exactly as the definition does (typeIDForPointer over the canonical, RFC 6901-escaped name), so it resolves regardless of how the reference happens to be spelled. This covers a schema literally named A/B (escaped A~1B), a name containing ~ referenced with a non-canonical escape (A~B for a component interned under A~0B), and the degenerate empty-named ("") component — which is interned anonymously and would otherwise dangle behind a namedTypeID.

Test plan

  • Referential closure is asserted through irverify.Verify — the shared IR structural checker — over 14 minimal reproducers under testdata/dangling/openapi/ plus the entire conformance corpus and the petstore golden: zero dangling references. Each reproducer is also pinned to its intended outcome (interned, or dropped with an openapi/unresolved-ref diagnostic).
  • Existing TestConformance goldens and the petstore snapshot are unchanged (no golden churn).
  • go test ./..., gofmt -l ., go vet ./..., golangci-lint run all clean, and the 100% statement-coverage gate holds.

Closes #14.

Several reference-like constructs lowered their target into an IR type or
auth ID without checking that the target actually interns, leaving the IR
with IDs that point at nothing:

- discriminator mappings and defaultMapping (a bare name, a $ref, or a deep
  pointer) synthesized a TypeID whether or not the schema existed;
- a $ref to a component sub-schema (e.g. .../Foo/properties/bar, a raw
  validation-only sub-schema, or a scalar under additionalProperties)
  derived a pointer ID that nothing was interned under;
- a $ref whose document part names the compiled file itself was treated as
  external and never lowered;
- a security requirement naming a scheme absent from
  components.securitySchemes wrote a dangling AuthID.

Reference resolution now follows one rule, matching how schema-position
$refs were already handled: if a target resolves to an internal same-file
schema, intern it (hoisting the sub-schema under its pointer-derived ID
when needed) so the reference is valid; otherwise emit an
openapi/unresolved-ref diagnostic and drop the offending entry. Same-file
document references resolve internally; genuinely external targets are
dropped, since Milestone 1 does not intern cross-file schemas. A schema
name spelled as a pointer segment ("A/B" -> "A~1B") now unescapes so it
resolves to the schema it names.

A dangling-reference oracle asserts referential closure across the twelve
reproducers and the full conformance corpus plus petstore. It walks every
ID-bearing site of a produced document, not only the ones this fix touched:
each TypeRef, discriminator, and value ref in the type registry, and the
whole service/operation tree — parameters, request and response payloads,
content items and file contents, part and response headers, error cases,
streaming, pagination, long-running, resource members, service renames, and
every scheme use — so a dangling reference reintroduced anywhere is caught.

Closes #14
Add unit tests for the interning-lookup and drop paths in reference
lowering that the existing suite left uncovered: internedID's byPointer
and type-registry hits, resolveSchemaRef reusing an already-hoisted
sub-schema versus dropping an unresolved deep $ref, hoistSubSchema
guarding a nil resolved body and returning a node its pointer already
owns, and discriminatorDefault dropping an unresolved defaultMapping with
a diagnostic. Restores compilers/openapi statement coverage to 100%.
The reference-lowering unification still left two paths that could write a
type ID with no interned node behind it, plus one same-file heuristic that
could misfire:

- A discriminator mapping to a bare component name always resolved to the
  component's namedTypeID, but a component whose name is the empty string is
  interned anonymously (componentSchemaName rejects an empty trailing
  segment), so the mapping pointed at nothing. Derive the target through
  typeIDForPointer so it matches the ID the component was interned under.

- resolveComponentRef built the resolved ID from the raw $ref pointer text,
  so a reference that escaped its pointer non-canonically (a bare '~' for a
  component named "A~B", interned under "A~0B") produced an ID that did not
  match the interned node. Rebuild the ID from the component's canonical
  name so any equivalent spelling resolves to the same node.

- sameFile compared a $ref's document part to the source purely by basename,
  which would treat a genuine cross-directory reference (dir2/m.yaml from
  dir1/m.yaml) as a self-reference. Restrict the basename shortcut to a bare
  filename and match a directory-qualified document part in full.

The referential-closure test now delegates to irverify.Verify, the shared
structural checker, instead of a bespoke IR walker, so it tracks new
ID-bearing fields automatically. Reproducers f31 and f32 are added, and the
spec-check harness allowlist is reconciled with the new fixtures.
@OmarAlJarrah
OmarAlJarrah merged commit 61d9a01 into main Jul 21, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the fix/openapi-dangling-refs branch July 21, 2026 14:26
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: unverified reference targets are lowered to dangling IR type/auth IDs

1 participant