Skip to content

refactor(compilers/openapi): reduce the root to its public face - #1104

Merged
OmarAlJarrah merged 22 commits into
mainfrom
refactor/openapi-root-public-face
Oct 23, 2026
Merged

OmarAlJarrah merged 22 commits into
mainfrom
refactor/openapi-root-public-face

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Oct 23, 2026 •

Copy link
Copy Markdown
Member

Summary

compilers/openapi is meant to hold the compiler's public face and the assembly behind it: Compiler, its Options and run. It also held document-level lowering (meta.go, servers.go) and most of detection (detect.go: the probe of a parsed root, and the byte scan for a source that will not parse). Both move into packages under internal/. The root's production files are now doc.go, openapi.go, options.go and detect.go, the last holding only Detect and the byte budget it reads from the caller's options.

  • internal/document lowers what a document says about itself: info, root externalDocs and servers. It also keeps verbatim what the objects around them write that lowers to no node of its own: extensions, undeclared keys, jsonSchemaDialect, components/mediaTypes and unreferenced components. docMeta and lowerMeta become document.Meta and document.Lower. The files split by what each lowers: info.go, servers.go, sites.go, components.go and dialect.go.
  • internal/detect recognizes a source from its bytes behind one entry, Recognize(srcIndex, data, maxBytes), which answers what Detect answers. Detect is now one call.
  • One site list. The x-* and the undeclared keys of the objects around the metadata were each collected over a list of their own: rootExtensions and tagExtensions for one, rootUnknownSites and tagUnknownSites for the other. The lists named the same objects at the same scopes and pointers, and twin tests kept their nil guards alike. One list, sites, now serves both readers, and the extensions are read off the object each site holds, so a site cannot name one object for its keys and another for its x-*. TestSites_ListEveryObjectAroundTheMetadata replaces the twin tests and holds each site to its scope, its pointer and, by identity, the object it reads.
  • The root no longer imports the parser library or yaml.v3. Its archtest allowlist is now ir, compilers, compilers/compile and its own internal/ tree, and each new package has a rule of its own. internal/document's names two more than openapi: move document lowering and detection's byte reading out of the root package #1091 listed: compilers/compile, which names a server, and the library's extensions package, which types the site list.

The IR, the diagnostics, the goldens and the exported API are all unchanged.

Where this departs from #1091's draft

  • servers.go moves too, into internal/document. Kept in the root, it would have had to reach document.Lower as a function parameter, a seam that exists only because of where a file sits. Keeping it there does nothing for path-item and operation servers (ir: decide whether four no_ir_home operation constructs get real fields #285) either. internal/operation can never import the root, which imports it, but it could import internal/document, or the lowering could move to a package both walks reach. That choice belongs to the change that gives those servers a field. Until then the document's list is the lowering's one reader, and internal/document's package comment says so.
  • internal/detect exports one entry, not four parts. The draft exported sniff, declaresProbeKey, majorMinor and sniffProbe for Detect to switch on. Deciding which decline to report needs what only the probe knows: a merge read it cut short, and its scan of unreadable bytes. So the switch stays with the probe, recognized with it, and nothing else is exported. Recognize takes no path, which makes Detect's "the path is not consulted" true by construction.
  • Tests follow their code into the new packages, the ones that compile a spec end to end included. Those live in each package's external test package, as auth's do. Coverage is counted per package, so a test left in the root would cover nothing in document or detect. The root keeps the tests of its own code: the byte budget Detect reads from the options, now asked of detectionBudget directly as well, and detection's agreement with Compile. Detection tests that called Detect now call Recognize and are named TestRecognize_*, and two of their tables lose a path column that Recognize cannot read. rootMergeChain, now wanted by two test packages, moves to openapitest.
  • Comments the moves carried, or left describing the old layout, are corrected in commits of their own: "Detect" where Recognize now does the asking, "below" pointing at tests in another file, a load test placing a moved test in the root package, resolve.Reached saying no lowering reads it when document.Lower does, and the root package comment and docs/architecture.md's layout. More comments said what the code does not:
    • Detect's and Recognize's comments counted a root merged past the depth detection reads among this compiler's own sources. Unless its own keys settle the version, such a root is declined with an error whatever its format, as docs/compilers/openapi.md#yaml-decoding says: a YAML file of another format merging 65 levels deep draws it, on main as here. Both comments now name the two declines that report whatever the format, and TestRecognize_AReadCutShortIsReported gains a row whose chain holds another format's keys. The rule's own wording is corrected too, below.
    • auth's package comment and archtest rule called the root's run "the document walk", which now reads as internal/document. Beside it they said neither half of the compiler may reach the other and that each half's lowering, put with its caller, would make the other import it. The root imports the operation walk, and the halves share no code, so the comment now gives the reason that holds.
    • lowering's package comment gave the same reason for the schema and operation walks, though the operation walk imports the schema walk. It now says why the context sits beneath every lowering package.
    • CLAUDE.md ran the order among a compiler's internal packages "up to operation", the one package at its top while the root held document lowering and detection. document and detect sit there beside it now, and the line names all three.
    • Comments naming what another package declares named it bare, as though it sat beside them: two document tests' auth twins, serverName's operationName, the refusal load.Load gives, and what the compiler's Detect passes. Each now names its package, as serverVariables already said annotation.XMLHints.
  • docs/micro-compiler-design.md §5.1 gains an as-landed note. Its target layout put meta under operation/, as the draft recalls, and auth too; the note records where both went and what reaches each.

#1096's done check listed servers.go among the root's files; it is edited to match, with a comment there saying why.

Carried across #1106

#1106 landed first, for #1096's reason: a behaviour change goes before a move. It edited two files this PR moves, so main is merged in (9b74bd5d) and each edit carried to where its code now lives:

  • compilers/openapi/meta.go: keepJSONSchemaDialect and keepComponentMediaTypes, which now read through nodeview.Child, replace their moved copies in internal/document/dialect.go and components.go.
  • compilers/openapi/meta_test.go: the x-bad: {n: .nan} fixture of TestMeta_UnserializableExtensionStillWarns goes to internal/document/sites_test.go.
  • internal/openapitest/spec.go and spec_test.go keep both sides' fixture builders and their tests, RootMergeChain and NestedMerges.
  • internal/archtest/arch_test.go's auth rule keeps this branch's reason with the sentence refactor(compilers/openapi): unify YAML reading and node naming #1106 appended about reading through nodeview.

Built on what #1106 introduced

  • settledByOwnPairs asks nodeview.WrittenChildren for the keys the root writes itself, instead of walking its raw pairs through nodeview.KeyOf by hand. refactor(compilers/openapi): unify YAML reading and node naming #1106 added that function for exactly this question and left the site alone only because detection was moving. Nothing held the function to the root's own keys: reading them through its merges passed every test, because the row named "a merged version a deeper one may outrank" defined its version and never merged it. It now merges it after the chain, and that reading turns the row red.
  • keepJSONSchemaDialect hands nodeview.Child the root node, as every other caller does. It unwrapped the document node first, which Child does itself.
  • The yaml-decoding rule says which roots past the merge bound are declined. It said a root "writing no version of its own" is; one that writes swagger alone is declined too, since an openapi past the bound would outrank it. The rule now states what settledByOwnPairs checks, a row of TestSniff_ReadsAMergeChainAsFarAsTheViewExpandsIt pins the swagger case, and the rule names that test.
  • docs/architecture.md lists every package under the compiler, each after every package it imports. The layout left out anchors, which refactor(compilers/openapi): unify YAML reading and node naming #1106 added, and older packages besides, the test-only ones among them, and still called nodeview the resolver's reading.

retainUnreferencedComponents keeps its nodeview.View beside keepComponentMediaTypes' Child: it enumerates each section's entries and warns when the merge bound cut that short, which only a view can tell it. Past the bound the two do not disagree: a section's entries are reported as not kept, and a mediaTypes the bound hides as unreachable.

Closes #1091. Closes #1107.

Test plan

  • Each commit, checked out alone: gofmt, go vet ./..., go build ./..., golangci-lint at the pinned v2.14.0, and the tests of compilers/..., internal/archtest, internal/harness, internal/conformance, cmd/... and engine/.... On the head, make fmt vet build too, which adds the repository's analyzers.
  • make coverage on the head, main merged in: go test ./... -race, and cmd/morphic-covergate reports all 15,144 statements covered.
  • A go/ast dump of every top-level declaration, compared before and after each move commit:
    • The 45 declarations internal/document received are byte-identical with their doc comments, apart from the Meta and Lower renames. The one exception is a test comment that named lowerMeta and now names Lower.
    • The 27 production declarations internal/detect received are byte-identical. Recognize is new: it holds the old body of Detect, minus the options.
    • The 41 test declarations it received differ only by the TestDetect_ → TestRecognize_ names, the Detect → recognize call (one of them now two lines), the two path columns and the openapitest.RootMergeChain qualifier.
    • Since the moves, three of those functions have changed, each described above: keepJSONSchemaDialect and keepComponentMediaTypes by the merge with refactor(compilers/openapi): unify YAML reading and node naming #1106, and settledByOwnPairs by the commit built on it.
  • go test -list over compilers/openapi/... before and after gives the same test names, apart from the TestDetect_ → TestRecognize_ renames, TestSites_ListEveryObjectAroundTheMetadata in place of TestTagExtensions_NilEntrySkipped and TestTagUnknownSites_NilEntrySkipped, and four new tests: TestRecognize_HoldsTheParseToItsByteBudget, TestRecognize_ADeclineNamesTheSourceItWasHanded, TestRootMergeChain_MergesEachLevelIntoTheNext and TestDetectionBudget_ReadsTheCallersOptions.
  • morphic compile and morphic validate, built at main (87e5d11f, refactor(compilers/openapi): unify YAML reading and node naming #1106 included) and at this branch's head, over 6,728 files: everything under testdata/ plus 6,388 YAML and JSON files from real-world API projects, most of which are not specs, so detection's declines are compared too. Stdout, stderr and exit code are byte-identical for every file. morphic-harness over testdata/ and the same files gives identical output.
    • The differential can see a change in this code: dropping the components site from sites changes the output for 2 files under testdata/, extensions-x.yaml under compile and unknown_keys.yaml under both commands, and silencing the undecodable-source report changes the one malformed spec in the corpus, the one file detection reports as undecodable.
    • It does not reach every decline. No file merges past the depth detection reads, and none comes near the byte budget. Those two are held by the tests below and by TestRecognize_AReadCutShortIsReported, which turns red when the cut-short decline is silenced.
  • Mutants, each turning the named test red:
    • Recognize naming source 0 whatever it is handed: TestRecognize_ADeclineNamesTheSourceItWasHanded.
    • Recognize ignoring its byte budget: that test and TestRecognize_HoldsTheParseToItsByteBudget.
    • Detect passing no budget, or another source index: TestDetect_DeclinesASourcePastTheCallersByteBudget.
    • detectionBudget reading another compiler's options as no budget, or leaving a negative budget untranslated: TestDetectionBudget_ReadsTheCallersOptions. The first passed every test before it.
    • RootMergeChain one level short: TestRootMergeChain_MergesEachLevelIntoTheNext.
    • settledByOwnPairs reading the root's keys through its merges, or a swagger the root writes alone settling the version: TestSniff_ReadsAMergeChainAsFarAsTheViewExpandsIt. The first passed every test before its row merged the version it names.
    • Recognize reporting a cut-short read only when the bytes name openapi: TestRecognize_AReadCutShortIsReported.
    • sites handing a site another object, each of the contact's site the license, a tag's externalDocs site the root's, and the components site the root: TestSites_ListEveryObjectAroundTheMetadata. Before it held the objects, the first drew only the root package's conformance tests.
  • CI's gate.

The root package lowered document-level metadata itself: meta.go's info,
extensions, unknown keys, retained components and dialect, and servers.go's
server lowering. That is lowering, not the compiler's public face, so it moves
to internal/document, split by what each file lowers.

Servers move with it rather than staying behind a callback the root would
hand in. The document's list is the only one the IR holds, so the document
is the lowering's one reader; a field for path-item and operation servers
would move it to a package both walks reach, whichever package it leaves.

docMeta and lowerMeta become document.Meta and document.Lower. Every other
moved declaration is byte-identical, and the diagnostics keep their order,
since Lower still calls the server lowering where lowerMeta did.

The tests follow the code: unit tests into _internal_test.go files, the specs
compiled end to end into the external test package, as auth's are. The root
no longer imports the parser library, so its allowlist drops it.
Most of detect.go was not the Detect method but how detection reads a
source: the parse and the probe of its root mapping, and the byte scan that
decides whether a source that will not parse is this compiler's to report.
That moves to internal/detect behind one entry, Recognize, which takes the
source index, the bytes and the byte budget and answers as Detect does.
Detect is left turning the caller's options into that budget.

One entry, rather than exporting the probe's parts for Detect to switch on:
which decline to report needs what only the probe knows, its cut-short
merge read and its scan of unreadable bytes, so the switch belongs with it.
Recognize takes no path, which makes Detect's "the path is not consulted"
true by construction, and the test tables lose the path column they carried
for nothing.

The tests follow the code, renamed TestRecognize_* where they called
Detect. The root keeps those of its own: the byte budget read from the
caller's options and detection's agreement with the compile, now in its
external test package. Two new tests pin from inside the package what only
the root's tests reached: the byte budget, and that every decline names the
source index it is handed. rootMergeChain, wanted by both packages, moves
to openapitest.

The root no longer imports yaml.v3, so its allowlist drops it.
The moves carried comments that described their old home. Detection's
comments named Detect where Recognize now does the asking; two test
comments pointed "below" at tests in another file; one called a test of
Recognize a test of the public face; Meta's comment spoke of "every function
below" in a file that now holds one. Each names what it means now.

Lower's comment also states its precondition, that it runs after every
other lowering, which only retainUnreferencedComponents' comment said.

The root package comment no longer lists recognition and document metadata
among what the package holds, and docs/architecture.md places version
sniffing in internal/detect and adds both packages to the layout.
The x-* and the undeclared keys of the objects around the document
metadata were each collected over a list of their own: rootExtensions and
tagExtensions for one, rootUnknownSites and tagUnknownSites for the other.
The two lists named the same objects at the same scopes and pointers, gave
the same reasons twice, and needed a pair of twin tests to keep their nil
guards alike.

One list, sites, now serves both readers. Each site holds the parsed
object, and the extensions are read off it, so a site cannot name one
object for its keys and another for its x-*. The order is unchanged, and
so is the order of the diagnostics each reader reports.
Section 5.1's target layout put meta and auth under operation/. Neither
landed there: auth founded a package because both halves of the compiler
reach it, and document metadata and detection stayed in the root until
each became a package of its own. The section's as-landed note now says
so, as the document records the other places where its layout and the
tree parted.
document's package comment said a field for path-item and operation
servers would move their lowering to a package both walks reach. That is
one of two choices: the operation walk could reach internal/document
instead, and the change that adds the field decides which. The comment
now names both.

Detect's comment said only that unreadable bytes declaring a version are
reported. A root merging deeper than detection reads is declined with an
error as well, as Recognize's comment already says; Detect's says so too.
Comments still described the old layout, or named nothing in the new:

- load's server-repeats test placed
  TestDuplicateServerName_CanonicalCollisionsAreNotReported in the
  openapi package; it is the document package's now.
- resolve.Reached said no lowering reads it and the compiler asks it.
  Its one reader is document.Lower, a lowering itself, which asks once
  every other lowering has returned; lowering.Ctx.Reached says the same.
- Compile's step comment named the last lowering meta.
- serverVariables likened its direct field read to an unqualified
  XMLHints, which names nothing in its package: annotation.XMLHints.

TestLowerServers_EveryEntrySkippedIsNil also spoke of "the same guard"
with nothing beside it to say which, a reference the move put further
away. It names the schemes' test whose guard it mirrors.
micro-compiler-design.md's note on resolve.Reached, and the field comment
in lowering.Ctx, both said no lowering reads the record and the compiler
reads it once the last lowering returns. Its one reader is
document.Lower, itself a lowering, which reads it once every other
lowering has returned. Both now say that, as resolve.Reached's own
comment does.

The note on where meta and auth landed also mixed two kinds of note.
The document marks how the restructuring landed with "As landed" and
what changed after it with "Later", so the part #1091 did is now a
"Later" note of its own.
Detect's and Recognize's comments said a decline explains itself only
when the source is this compiler's own, and counted a root merged past
the depth detection reads among those. It is not one. Unless the root's
own keys settle the version, it is declined with an
openapi/budget-exceeded error whatever its format, since a version past
the bound may outrank one read above it, as
docs/compilers/openapi.md#yaml-decoding says. A YAML file of another
format whose root merges 65 levels deep and declares no version anywhere
draws the error, on main as here.

Both comments now name the two declines that report whatever the
format, the byte budget and the cut-short read, and keep the source's
being this compiler's own for the one that declares a version and cannot
be read.

The recognize test helper said it calls Recognize as Detect does, with
no byte budget; Detect passes the caller's. It now says the helper reads
Detect's source with no budget.
TestRecognize_AReadCutShortIsReported held the bound to a chain with an
openapi version at its foot, so a decline that reported only bytes
naming that key passed it. A second row puts another format's keys there
instead, and reporting the cut only when the bytes name openapi now
turns it red.
TestSites_ListEveryObjectAroundTheMetadata named every site's scope and
pointer but not the object it holds, so a site reading the wrong object
passed it and only the root package's conformance tests noticed. It now
checks each by identity, the one way to tell the root's externalDocs
from a tag's. Giving the contact's site the license, a tag's
externalDocs site the root's, or the components site the root each turns
it red.
auth's package comment, and its archtest rule, said the document walk
lowers the schemes. With internal/document a package, that reads as
document.Lower, which lowers none; the root's run does. They also said
neither half of the compiler may reach the other, and that putting
either half's lowering with its caller would make the other import it.
The root imports the operation walk, and the two halves share no code,
so neither holds.

The comment now gives the reason that does: the requirements cannot sit
in the root, which the operation walk may not import, and the schemes
are the document's, no part of lowering an operation. The as-landed note
in micro-compiler-design.md, which repeated the old reason, now says
which part of the compiler reaches each half instead.
lowering's package comment gave the reason auth's did, that the schema
walk and the operation walk both need it and neither may reach the
other. The operation walk imports the schema walk, as
micro-compiler-design.md §5.1, which the comment cites, lays out. What
holds is that every lowering package reads the context and only a
package beneath them all serves each: the schema walk may not reach the
operation walk, and auth and document reach neither.
detectionBudget gives options of another compiler's type this
compiler's default byte budget, as compilers.Compiler.Detect requires.
Nothing held it to that: reading them as no budget at all passed every
test, because Detect's tests exceed only a budget the caller sets, and a
source past the 64 MiB default costs more than the gate's race run
should pay. A table now asks detectionBudget directly for no options,
another compiler's, a budget of the caller's own and one turned off, and
both that change and leaving a negative budget untranslated turn it red.
Two document tests name an auth test as their twin, TestAuth_ and
TestLowerSecuritySchemes_, as though it sat beside them. Both say auth's
now, as load's test already says the document package's of the one it
names.
serverName likened its hint to operationName, which is the operation
package's. Recognize and its budget test named the refusal Load gives,
which is load.Load's. Two detect test comments said what Detect passes,
which is the compiler's, and a document test said Load's tests for the
load package's. Each now names where the thing lives, as serverVariables
already says annotation.XMLHints.
#1106 changed two functions of meta.go and one fixture of meta_test.go,
which this branch moved into internal/document. Each lands in the file
that holds its moved copy: keepJSONSchemaDialect in dialect.go and
keepComponentMediaTypes in components.go, both now reading through
nodeview.Child, and the x-bad: {n: .nan} fixture in sites_test.go.
openapitest keeps both sides' builders, RootMergeChain and NestedMerges,
and the auth rule in internal/archtest keeps this branch's reason with
the sentence #1106 appended about reading through nodeview.
settledByOwnPairs walked the root mapping's raw pairs by hand, asking
nodeview.KeyOf for each key, to learn which keys the root writes itself.
nodeview.WrittenChildren, which #1106 added, answers exactly that: the
keys a mapping writes, no merge read, each named as KeyOf names it. The
probe now asks it, so the compiler has one reading of a mapping's own
keys; #1106 left this site alone only because detection was moving.

Nothing held the function to its own keys. Reading the root's keys
through its merges instead passed every test, because the row named "a
merged version a deeper one may outrank" defined that version but never
merged it, and so repeated "a chain past it". It now merges the version
after the chain, where the read finds it and the bound may hide one that
outranks it, and the merged reading turns it red.
keepJSONSchemaDialect unwrapped the document node before asking
nodeview.Child, which unwraps it itself. Every other caller, its sibling
keepComponentMediaTypes included, hands Child the root node as the
parser gives it; this one now does too.
The yaml-decoding rule declined a root that merges past the depth
detection reads only when it writes no version of its own. One that
writes swagger itself is declined too: detection reads openapi first, so
an openapi a merge past the bound may hold would outrank it. The rule
now states the condition settledByOwnPairs checks, that the root writes
openapi with a version or writes both keys, and that the decline holds
whatever the source's format. A row of
TestSniff_ReadsAMergeChainAsFarAsTheViewExpandsIt pins the swagger case,
and the rule names that test.
The layout listed the compiler's internal packages as "its own
packages", but left out anchors, which #1106 added, and fragment,
navigation, overlay, sourceindex and spelling before it. nodeview's line
still read "as the resolver reads it", where its own package comment now
says the parser's reading and one bounded walk. The list now names every
package, the test-only ones on a line of their own, in an order where
each comes after every package it imports, which value importing
nodeview and load importing resolve had broken.
CLAUDE.md said the order among a compiler's internal packages runs from
diag up to operation. operation was the one package nothing else
internal imports while the root held document lowering and detection;
document and detect sit beside it at the top now, so the line names all
three.
@OmarAlJarrah
OmarAlJarrah merged commit ca345c1 into main Oct 23, 2026
2 checks passed
@OmarAlJarrah
OmarAlJarrah deleted the refactor/openapi-root-public-face branch October 23, 2026 18:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant