Skip to content

feat(compilers/openapi): fill typed fields from vendor extensions - #355

Merged
OmarAlJarrah merged 5 commits into
mainfrom
feat/openapi-extension-sourced-fields
Aug 11, 2026
Merged

feat(compilers/openapi): fill typed fields from vendor extensions#355
OmarAlJarrah merged 5 commits into
mainfrom
feat/openapi-extension-sourced-fields

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

Several typed IR fields model information OpenAPI gives no keyword for, so the only way a document
can state it is a vendor extension — and nothing connected the two. x-deprecated-reason landed in
Unmodeled and ir.Deprecation.Message stayed empty at every one of the seven places the compiler
builds a Deprecation, with no diagnostic either way. Any emitter wanting the value had to reach
into Unmodeled with an ad-hoc x-* lookup, which is exactly what an emitter must not do.

The deliverable is the mechanism, not the two keys that demonstrate it.

The rule, recorded first. docs/ir-design.md §12 now states what promoting a vendor extension
into a typed field means: the mapping is injectable policy, default on and disableable; the
extension stays in Unmodeled with its vendor_extension reason, so a promotion is a second
reading and never a move; the node records the heuristic in its own Provenance.Inferred; and a
node with no Provenance is not promoted into, because a guess nobody can audit is worse than an
empty field. Reading an extension is a heuristic — the format assigns x-* no semantics at all —
so invariant 6 governs all of it.

The seam. Options.Promotions maps an extension key to a member of a closed ExtensionTarget
vocabulary. Adding a target later is a constant and an applier, not a change to the policy shape or
to any call site. The default mapping is deliberately small and obviously a default:
x-deprecated-reason, x-deprecated-since and x-sunset are conventions no specification defines,
and a caller may replace the mapping outright rather than extend it.

What it fills today: the three ir.Deprecation members, at every carrier that can record the
promotion — operation, response header, type, property and security scheme. Parameter is the one
carrier left out and the reason is in the code beside it: ir.Parameter has no Provenance, so
rule 3 cannot be satisfied there, and giving it one is a change to ir-design.md rather than to
this compiler. The other extension-sourced fields — Pagination.*, LongRunning, Idempotency,
ErrorCase.Retryable/Throttling, Enum.Flags, EnumMember.Name, Sensitive, Secret — are
follow-up targets for this vocabulary; ErrorCase.Retryable/Throttling additionally needs #275,
since response extensions are not read at all until it lands.

Merge ordering: #341 adds DecodeOptions to the compiler contract, and #345 and #352 also rewrite
unwitnessed.golden.txt. Whichever lands first, the others rebase; once #341 is in, Promotions
should gain textual option names there so the CLI's -opt path can reach it.

Test plan

  • New corpus row extension-promotion names all five carriers individually, so a construction site
    that stops promoting fails on its own row rather than being covered by a neighbour. It also pins
    the two refusals: an extension beside no deprecated: true annotates nothing, and a value that is
    not text fills no field and is reported.
  • unwitnessed.golden.txt loses three entries — Deprecation.Message, Deprecation.Since and
    Deprecation.RemovalVersion. Provenance.Inferred was the fourth when this branch was written;
    the streaming media-type heuristic witnesses it already, so it left the list with that change.
  • Unit tests pin that the promotion leaves the entry it read, that the marker joins whatever
    heuristic was already recorded and is not repeated when a node is annotated twice, and that both
    the exported default mapping and a caller's own map are copied rather than shared.
  • Policy tests cover the off switch and a caller's mapping replacing the defaults rather than
    extending them.
  • Full gate: gofmt, vet, golangci-lint, build, and the 100% coverage gate.

Closes #252

lowering.New now takes three caller policies: the budgets and the streaming
media list arrived on main (#349, #352), the promotion mapping here. The field
comment naming Grouping "one of the two" no longer counts them, and both policy
types had called themselves "the second injectable-policy seam" — with Grouping
there are three, so neither is second.

Two lowerings this branch hooked into were refactored under it. An operation's
extensions are gathered by applyOperationExtensions now, and a security
scheme's by applySchemeExtensions, so the promotion runs after each rather than
beside the inline reads it used to follow — it reads the assembled map, so it
has to.

The conformance table gained its capability column on main (#342);
extension-promotion names the deprecation row, whose members are the fields it
fills. It does not name vendor-extensions: extensions-x witnesses that already,
and the x-* here are the means rather than the subject.
A target is a constant and an applier, and only the constant was held to
anything. Declaring one without the other compiles, maps cleanly and promotes
nothing — no field written, no diagnostic, no marker — which is precisely the
shape every follow-up target arrives in: Pagination, Idempotency, Sensitive and
the rest are constants waiting for an applier apiece.

The declared set is read off the source rather than restated, so a constant
added to the vocabulary reaches the check without anyone remembering to list it
twice, and the read is held to finding something so a moved declaration fails
instead of passing vacuously.

A target whose family this package cannot yet apply fails here on purpose:
adding one means adding its applier and teaching this test which applier
answers for it, the way a new census keyword means adding its arm.
…ing it

The guard read one file with a regex, so it missed a target declared in another
file of the package and one spelled with different spacing — silently, in both
cases. That is the failure the guard exists to prevent, reproduced inside the
guard: a check that does not reach reads exactly like a check that passes.

It parses the package now and takes every const whose declared type is
ExtensionTarget, which is independent of file, spacing and grouping. All three
spellings that slipped past the regex are caught, and the read is still held to
finding something, so renaming the type fails rather than passing vacuously.
The parse took the declared type only, so `X = ExtensionTarget("...")` — legal
Go, and a target like any other — was read as no target at all. A vocabulary
entry added that way would have promoted nothing with nothing to say about it,
which is the defect this check exists for, missed by the check for the third
time in three spellings.

It reads both forms now. All four ways a target can be declared are caught: the
typed const the file uses, the same with odd spacing, a var in another file of
the package, and the conversion. The read is still held to finding something,
so renaming the type fails rather than passing on an empty set.
@OmarAlJarrah
OmarAlJarrah merged commit 78a1b68 into main Aug 11, 2026
1 check passed
@OmarAlJarrah
OmarAlJarrah deleted the feat/openapi-extension-sourced-fields branch August 11, 2026 09:45
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.

compilers/openapi: typed IR fields whose only source is a vendor extension have no route to fill them

1 participant