Skip to content

feat(engine)!: let compilers own format detection and options - #341

Open
OmarAlJarrah wants to merge 5 commits into
mainfrom
feat/compiler-owned-format-and-options
Open

feat(engine)!: let compilers own format detection and options#341
OmarAlJarrah wants to merge 5 commits into
mainfrom
feat/compiler-owned-format-and-options

Conversation

@OmarAlJarrah

@OmarAlJarrah OmarAlJarrah commented Aug 9, 2026

Copy link
Copy Markdown
Member

Summary

Two gaps at the boundary between engine, compilers and the CLI, fixed with one seam.

Format detection lived in the engine. engine/sniff.go YAML-decoded the whole source and knew
exactly two keys. Three of the five planned compilers take input that is not YAML, so a .proto,
.tsp or .graphql file died with the decoder's complaint — quoting the engine's own private type
name 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.Compiler now carries Detect(Source) (SourceFormat, []ir.Diagnostic, bool), and
Registry.Detect asks
each registered compiler in registration order. The OpenAPI compiler owns the two discriminating
keys and the major.minor version grammar that used to sit in engine/sniff.go; engine names no
source format anywhere, and its archtest allowlist no longer includes a YAML parser. Recognition is
deliberately not support — the OpenAPI compiler reports swagger@2.0 while 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 Compile already reports on. It
stays 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: [unterminated is an
OpenAPI 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_BeyondTheCap
pins in both directions.

No compiler option was reachable without writing Go. cmd/morphic/compile.go built its
engine.RunOptions with SkipValidate alone; FormatOptions appeared nowhere under cmd/. The
options themselves worked fine from a Go embedder, so this was a feature that existed and could not
be reached.

compilers.Compiler also gains DecodeOptions(OptionSet) (any, error), and both spec-taking
commands gain a repeatable --opt key=value. It is bound with the other shared flags rather than by
compile alone: validate is compile's pipeline with the document dropped, so a spec needing an
option 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. OptionSet also carries the caller's file
reader, which is what lets a path-valued option work while a compiler still does no file I/O of its
own — engine supplies os.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 compilers gained format
knowledge here — it lost some. engine no longer holds a spec's discriminating keys, its version
grammar, or a parser; the CLI holds no option vocabulary. What remains in engine.New is the
one-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 engine
composes explicitly. Registering a compiler needs no engine edit, which
TestEngine_RunNewFormatNeedsNoEngineEdit proves with a compiler for a format no file under
engine/ mentions.

internal/harness still bypasses detection by calling the compiler directly; that is deliberate and
unchanged. The clearer failure this gives an unrecognized source may make the exit-code and
diagnostic work easier, but nothing about how diagnostics leave engine.Run is touched here.

Two corrections to #69's text

  • The field is AllowExternalRefs, not DisableExternalRefs; the polarity was inverted after the
    issue was filed and it is now off by default.
  • There are three unreachable fields, not two. Overlay is the third, and its own doc comment
    admitted 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_RunUnrecognizedFormat compiles .proto, .tsp and two GraphQL documents
    and asserts the error names no compiler and quotes no parser. Red on all five subcases with the
    old sniff restored.
  • cmd/morphic: TestRun_CompilerOptionReachesTheCompiler compiles a spec tagged zoo under a
    path starting a and asserts --opt grouping=path-prefix moves the group name, with a control
    run pinning that the default is what changed. Red ("a" vs "zoo") with the RunOptions line
    reverted.
  • compilers: registration-order, recognized-but-unregistered, declining and empty-source cases
    over Registry.Detect.
  • compilers/openapi: a Detect table over every dialect and four foreign formats; sniff past
    the cap in block and flow style; decodeFlowPrefix against prefixes cut inside a key and inside a
    value; the entry cap; DecodeOptions reaching all four settings and refusing seven bad ones;
    TestDecodeOptions_FeedsCompile closing the loop from decode to lowering.
  • cmd/morphic: --opt overlay=<file> end to end, asserting the overlay applied and a second
    source recorded; malformed, unknown and unusable settings refused with exit 2 and no document.
  • cmd/morphic/testdata/compile-help.txt regenerated for the new flag.
  • Full gate green: gofmt, go vet, golangci-lint (0 issues), go build, coverage at 100%.

Breaking

  • compilers.Compiler gains Detect and DecodeOptions. Every implementation must add both; the
    in-tree one is updated.
  • engine.Sniff and its probe type are removed. Detection is reached through a registry of
    compilers, which is the only place that can answer for more than one format.
  • engine/undecodable-source becomes openapi/undecodable-source and gains the parse position the
    engine-level code never carried. engine/unsupported-format retires: Swagger is recognized and
    served by nobody, which is engine/no-compiler-for-format, and the engine cannot distinguish that
    from an unregistered format.
  • NewWith() with no compilers reports engine/unrecognized-format rather than
    engine/no-compiler-for-format. With nobody to read the source, no format is named.
  • validate accepts --opt, which it did not before. Its help text and golden change with it.
  • engine.RunOptions gains CompilerOptions. Setting it together with FormatOptions is an error
    rather than a precedence rule, since a run configured two ways is a mistake in the caller.

Closes #68
Closes #69

@OmarAlJarrah OmarAlJarrah changed the title feat(engine): let compilers own format detection and options feat(engine)!: let compilers own format detection and options 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.
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.

cli+engine+compilers: make compiler options reachable end-to-end engine+compilers: move format detection to a compiler-owned seam

1 participant