feat(engine)!: let compilers own format detection and options - #341
Open
OmarAlJarrah wants to merge 5 commits into
Open
feat(engine)!: let compilers own format detection and options#341OmarAlJarrah wants to merge 5 commits into
OmarAlJarrah wants to merge 5 commits into
Conversation
This was referenced Aug 9, 2026
The branch was cut before the engine's format step became diagnostic-reporting,
so the two sides disagree about what happens when no compiler will take a
source. Resolved in favour of the merged behaviour, and the detection contract
widened so it can be honoured without the engine parsing anything.
main made every spec problem an ir.Diagnostic with exit 1, reserving the Go
error return — and the CLI's exit 2 — for a misuse of the tool or an I/O
failure. This branch's Run returned a Go error for a source no compiler
recognizes, which would have put "your spec is unreadable" back on the exit
code that means "you invoked morphic wrong".
Keeping both required a third answer. main told a source that does not parse
from one that parses and declares nothing, under engine/undecodable-source;
this branch's Detect could not, because a compiler may only say "not mine" and
the engine no longer parses. So Detect now reports diagnostics alongside its
verdict, matching Compile's channel:
Detect(src Source) (format SourceFormat, diags []ir.Diagnostic, ok bool)
A compiler declines another format's bytes silently, and reports only when the
source is recognizably its own and cannot be read. The registry carries what
the decliners said out to the engine, which prefers it over its own account —
it parses nothing and can say no more than that nobody claimed the source.
Consequences:
- engine/undecodable-source becomes openapi/undecodable-source, and now carries
the parse position, which the engine-level code never had. A malformed .proto
no longer reports "not YAML", which the engine-side probe would have said of
every format that is not YAML.
- engine/unsupported-format retires. Swagger is recognized by the OpenAPI
compiler and served by nobody, which is engine/no-compiler-for-format; the
engine cannot tell that from an unregistered format and should not pretend to.
- NewWith() with no compilers now reports unrecognized-format rather than
no-compiler-for-format: with nobody to read the source, no format is named.
Registry.Lookup keeps only test callers now that Run detects rather than looks
up. It is left in place as the read side of Register.
Registry.Detect ended its search on any compiler answering ok, including one that named no format at all. That answer says nothing — a compiler recognizing a source names what it recognized — and acting on it hides every compiler registered after: a source the next one would have taken comes back unrecognized, with nothing in the output naming the compiler that swallowed it. Skip such a compiler and keep asking. Its diagnostics are not collected, since the contract reads those only from a compiler that declined, and this one did not say it declined. Also pin the bound the key search shares with the decode. declaresProbeKey reads the same bounded prefix sniff does, so a source declaring an OpenAPI key only past the cap is declined silently rather than reported as this compiler's own and broken — claiming it would assert something about bytes detection never read. The truncation ran under the existing tests without any of them observing its effect; a source whose prefix fails to parse and whose key falls past the cap is the case that separates the two.
validate is compile's pipeline with the document dropped, so the two have to configure that pipeline the same way. --opt was bound only by compile, which left a spec that needs an option to compile impossible to check the way it would be built: `validate spec.yaml` and `compile spec.yaml --opt overlay=o.yaml` read different documents, and a gate could pass a spec the build then rejects. Bind it in bindSpecFlags with the rest of the shared flags, which also puts its spelling, default and help text under TestSpecFlags_SharedFlagsAgree rather than leaving two definitions to drift. bindSpecFlags now makes the settings map, so neither constructor has to remember to. Registry.Detect resolves its owner through Lookup rather than reaching into byFormat, which is the same read and leaves the registry's format-keyed lookup with a caller in the pipeline again now that Run detects instead of looking up.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Two gaps at the boundary between
engine,compilersand the CLI, fixed with one seam.Format detection lived in the engine.
engine/sniff.goYAML-decoded the whole source and knewexactly two keys. Three of the five planned compilers take input that is not YAML, so a
.proto,.tspor.graphqlfile died with the decoder's complaint — quoting the engine's own private typename at the user — rather than being told no compiler takes it. Worse, which of the two outcomes a
file got was an accident of whether its bytes happened to parse: a one-line GraphQL document is
valid YAML and reached "unrecognized spec format", a two-line one was a YAML error.
compilers.Compilernow carriesDetect(Source) (SourceFormat, []ir.Diagnostic, bool), andRegistry.Detectaskseach registered compiler in registration order. The OpenAPI compiler owns the two discriminating
keys and the
major.minorversion grammar that used to sit inengine/sniff.go;enginenames nosource format anywhere, and its archtest allowlist no longer includes a YAML parser. Recognition is
deliberately not support — the OpenAPI compiler reports
swagger@2.0while serving no such format,so a Swagger document is reported as a format nothing is registered for rather than an unreadable
file.
A compiler also reports why it declined, through the same channel
Compilealready reports on. Itstays silent for another format's bytes — a compiler that complained about every source it did not
take would bury the one report that matters — and speaks only where the source is recognizably its
own and cannot be read. That is the case nothing else can cover:
openapi: [unterminatedis anOpenAPI document with a syntax error, and calling it unrecognized is wrong twice, since the compiler
did recognize it and holds the parse error explaining it. The engine prefers what a compiler said
over its own account, because it parses nothing and can say no more than that nobody claimed the
source. Reporting it engine-side instead would mean labelling every unparsed source "not YAML",
which is false for the first compiler whose format is not.
Detection is bounded. A source within 64 KiB is decoded whole; a larger one is read from a bounded
prefix — flow style (JSON) has its top-level entries streamed, block style is cut at its last
complete line — so cost is flat in document size instead of a full parse to read two keys. Measured
on a 10 MB block-style spec: 3.0 ms against 449 ms for the whole-document decode it replaces. The
cost is stated where it lands: a declaration past the cap is not seen, which
TestSniff_BeyondTheCappins in both directions.
No compiler option was reachable without writing Go.
cmd/morphic/compile.gobuilt itsengine.RunOptionswithSkipValidatealone;FormatOptionsappeared nowhere undercmd/. Theoptions themselves worked fine from a Go embedder, so this was a feature that existed and could not
be reached.
compilers.Compileralso gainsDecodeOptions(OptionSet) (any, error), and both spec-takingcommands gain a repeatable
--opt key=value. It is bound with the other shared flags rather than bycompilealone:validateis compile's pipeline with the document dropped, so a spec needing anoption to compile has to be checkable under that same option, or a gate passes a spec the build then
rejects. The CLI carries the pairs verbatim: it does not know which compiler
will read them, so the names, the accepted values and what counts as a file path are the compiler's,
and an unknown name is refused rather than ignored.
OptionSetalso carries the caller's filereader, which is what lets a path-valued option work while a compiler still does no file I/O of its
own —
enginesuppliesos.ReadFile, so the read stays on the caller's side of the contract.Both additions are required interface methods rather than optional interfaces checked by type
assertion. A compiler that answered neither would register successfully and be unreachable, which is
a hole no caller can see; and since both issues wanted to widen the same one-implementation
interface, widening it once beats two seams that can silently not participate.
On invariant 1
The IR is the ABI and the engine must not learn formats. Nothing above
compilersgained formatknowledge here — it lost some.
engineno longer holds a spec's discriminating keys, its versiongrammar, or a parser; the CLI holds no option vocabulary. What remains in
engine.Newis theone-line composition root that names the built-in compilers, which is what a composition root is
for: the registry documents that there is no
init()-time self-registration and that the enginecomposes explicitly. Registering a compiler needs no engine edit, which
TestEngine_RunNewFormatNeedsNoEngineEditproves with a compiler for a format no file underengine/mentions.internal/harnessstill bypasses detection by calling the compiler directly; that is deliberate andunchanged. The clearer failure this gives an unrecognized source may make the exit-code and
diagnostic work easier, but nothing about how diagnostics leave
engine.Runis touched here.Two corrections to #69's text
AllowExternalRefs, notDisableExternalRefs; the polarity was inverted after theissue was filed and it is now off by default.
Overlayis the third, and its own doc commentadmitted only a programmatic caller could set it. That comment is now wrong in the other
direction and has been rewritten. Overlay is the strongest case of the three, since it takes a
file — hence the reader on
OptionSet.Test plan
Written first, and each confirmed to go red with only the production change reverted:
engine:TestEngine_RunUnrecognizedFormatcompiles.proto,.tspand two GraphQL documentsand asserts the error names no compiler and quotes no parser. Red on all five subcases with the
old sniff restored.
cmd/morphic:TestRun_CompilerOptionReachesTheCompilercompiles a spec taggedzoounder apath starting
aand asserts--opt grouping=path-prefixmoves the group name, with a controlrun pinning that the default is what changed. Red (
"a"vs"zoo") with theRunOptionslinereverted.
compilers: registration-order, recognized-but-unregistered, declining and empty-source casesover
Registry.Detect.compilers/openapi: aDetecttable over every dialect and four foreign formats;sniffpastthe cap in block and flow style;
decodeFlowPrefixagainst prefixes cut inside a key and inside avalue; the entry cap;
DecodeOptionsreaching all four settings and refusing seven bad ones;TestDecodeOptions_FeedsCompileclosing the loop from decode to lowering.cmd/morphic:--opt overlay=<file>end to end, asserting the overlay applied and a secondsource recorded; malformed, unknown and unusable settings refused with exit 2 and no document.
cmd/morphic/testdata/compile-help.txtregenerated for the new flag.gofmt,go vet,golangci-lint(0 issues),go build, coverage at 100%.Breaking
compilers.CompilergainsDetectandDecodeOptions. Every implementation must add both; thein-tree one is updated.
engine.Sniffand its probe type are removed. Detection is reached through a registry ofcompilers, which is the only place that can answer for more than one format.
engine/undecodable-sourcebecomesopenapi/undecodable-sourceand gains the parse position theengine-level code never carried.
engine/unsupported-formatretires: Swagger is recognized andserved by nobody, which is
engine/no-compiler-for-format, and the engine cannot distinguish thatfrom an unregistered format.
NewWith()with no compilers reportsengine/unrecognized-formatrather thanengine/no-compiler-for-format. With nobody to read the source, no format is named.validateaccepts--opt, which it did not before. Its help text and golden change with it.engine.RunOptionsgainsCompilerOptions. Setting it together withFormatOptionsis an errorrather than a precedence rule, since a run configured two ways is a mistake in the caller.
Closes #68
Closes #69