diff --git a/README.md b/README.md index 8571afe..562bd66 100644 --- a/README.md +++ b/README.md @@ -122,6 +122,7 @@ Help always prints to stdout and exits `0`. | `-o ` | `compile` | Write IR JSON to `` instead of stdout, compact rather than indented. | | `--pretty` | `compile` | Indent the JSON `-o` writes; stdout is indented either way. | | `--explain ` | `compile` | Report what compiling produced at this source coordinate instead of writing the document. | +| `--opt =` | both | Set one option on the compiler the spec selects. Repeatable; a repeated key is refused. | Diagnostics print one per line as ` : `, where `` is `#` for a finding in a spec file, a bare pointer for one an IR pass made about the @@ -133,10 +134,29 @@ covers an undecodable file, an unrecognized or unsupported format, and a version `2` the invocation or the filesystem was wrong — a bad flag or argument, a spec that could not be read, an output that could not be written. Nothing about the spec's own contents reaches `2`. +#### Compiler options + +`--opt` names an option in the vocabulary of whichever compiler recognizes the spec — morphic +itself knows none of them, and an unknown name is refused by the compiler rather than ignored. +The OpenAPI compiler accepts: + +| Option | Values | Meaning | +|---|---|---| +| `grouping` | `tags` (default), `path-prefix` | How operations are grouped into operation groups. | +| `allow-external-refs` | `true`, `false` (default) | Let `$ref` resolution leave the source document, reading files and fetching URLs. | +| `overlay` | a file path | Apply an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) document to the source before lowering. | +| `overlay-lax` | `true`, `false` (default) | Do not refuse when an overlay action's selector matches nothing. | + +```bash +morphic compile openapi.yaml --opt grouping=path-prefix --opt overlay=patch.yaml +``` + ### Library The same pipeline is available as a package. `engine.New` builds the default registry (OpenAPI -compiler + `validate` pass); `Run` sniffs the format, compiles, and runs passes. +compiler + `validate` pass); `Run` asks the registered compilers which of them recognizes the +source, compiles, and runs passes. A Go caller can set compiler options as a typed value through +`RunOptions.FormatOptions` instead of as text through `RunOptions.CompilerOptions`. ```go eng, err := engine.New() diff --git a/cmd/morphic/command_test.go b/cmd/morphic/command_test.go index 684137b..19da727 100644 --- a/cmd/morphic/command_test.go +++ b/cmd/morphic/command_test.go @@ -65,6 +65,9 @@ func TestNewCompileFlags_DefinesEveryFlag(t *testing.T) { assert.Empty(t, opts.outPath) assert.False(t, opts.skipValidate) assert.Empty(t, opts.explain) + assert.NotNil(t, opts.settings, + "the settings map must exist before Parse writes into it") + assert.Empty(t, opts.settings) } func TestNewValidateFlags_DefinesEveryFlag(t *testing.T) { @@ -105,8 +108,8 @@ func TestSpecFlags_SharedFlagsAgree(t *testing.T) { // asserted from both the constructor and the command-table entry so the two // cannot drift. var ( - compileFlagNames = []string{"o", "fail-on", "skip-validate", "explain", "pretty"} - validateFlagNames = []string{"fail-on", "skip-validate"} + compileFlagNames = []string{"o", "fail-on", "skip-validate", "explain", "pretty", "opt"} + validateFlagNames = []string{"fail-on", "skip-validate", "opt"} ) func TestCommand_PrintFlagsDocumentsTheCommandsOwnFlags(t *testing.T) { diff --git a/cmd/morphic/compile.go b/cmd/morphic/compile.go index bc25f74..b47e230 100644 --- a/cmd/morphic/compile.go +++ b/cmd/morphic/compile.go @@ -72,6 +72,9 @@ func newCompileCommand() command { "--explain reports what compiling produced at one source coordinate — the\n" + "type node interned there, the coordinates interned beneath it, and the\n" + "diagnostics stamped at it — instead of writing the document.\n\n" + + "--opt passes a setting to the compiler the spec selects, which names and\n" + + "validates its own options; morphic itself knows none of them. The OpenAPI\n" + + "compiler's are listed in the README.\n\n" + "A -- argument ends flag parsing: every argument after it is an operand,\n" + "even one that begins with a dash, which is how a spec file named like a\n" + "flag is passed.", @@ -84,11 +87,14 @@ func newCompileCommand() command { } } -// specOptions holds the values the flags shared by every spec-taking command -// parse into. +// specOptions holds what the shared pipeline runner reads, parsed by the flags +// every spec-taking command defines. validate is compile's pipeline with the +// document dropped, so the two configure that pipeline the same way: a spec that +// needs an option to compile needs it to be validated as it will be compiled. type specOptions struct { failOn string skipValidate bool + settings settingFlag } // compileOptions holds the values compile's flags parse into. @@ -103,10 +109,14 @@ type compileOptions struct { // Sharing the registration is what keeps their spellings, defaults and help // text identical across commands rather than identical-looking. func bindSpecFlags(fs *flag.FlagSet, opts *specOptions) { + opts.settings = settingFlag{} + fs.StringVar(&opts.failOn, "fail-on", "error", "fail (exit 1) on diagnostics at or above this severity: error|warning") fs.BoolVar(&opts.skipValidate, "skip-validate", false, "skip the referential-integrity validate pass") + fs.Var(opts.settings, "opt", + "set one `key=value` option on the compiler the spec selects (repeatable)") } // newCompileFlags returns compile's FlagSet and the options its flags write @@ -180,7 +190,10 @@ func runPipeline(specPath string, opts specOptions, stderr io.Writer) (*engine.R return nil, 2, false } - res, err := eng.Run(context.Background(), specPath, engine.RunOptions{SkipValidate: opts.skipValidate}) + res, err := eng.Run(context.Background(), specPath, engine.RunOptions{ + CompilerOptions: opts.settings, + SkipValidate: opts.skipValidate, + }) if err != nil { emitf(stderr, "morphic: %v\n", err) return nil, 2, false diff --git a/cmd/morphic/compile_test.go b/cmd/morphic/compile_test.go index 1f2e98f..e3d305f 100644 --- a/cmd/morphic/compile_test.go +++ b/cmd/morphic/compile_test.go @@ -56,8 +56,8 @@ func TestRun_SpecProblemsExitOne(t *testing.T) { name, contents, code string }{ {"unrecognized format", "hello: world\n", "engine/unrecognized-format"}, - {"swagger 2.0", "swagger: \"2.0\"\n", "engine/unsupported-format"}, - {"undecodable source", "openapi: [unterminated\n", "engine/undecodable-source"}, + {"swagger 2.0", "swagger: \"2.0\"\n", "engine/no-compiler-for-format"}, + {"undecodable source", "openapi: [unterminated\n", "openapi/undecodable-source"}, {"no compiler for version", "openapi: 4.0.0\ninfo: {title: T, version: \"1\"}\npaths: {}\n", "engine/no-compiler-for-format"}, } @@ -140,6 +140,95 @@ func TestRun_UsageErrors(t *testing.T) { } } +// groupingSpec tags its one operation "zoo" while its path starts with "a", so +// the two grouping strategies name the group differently and the assertion below +// cannot pass by accident. +const groupingSpec = `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a/b: + get: + operationId: ab + tags: [zoo] + responses: {"200": {description: ok}} +` + +// TestRun_CompilerOptionReachesTheCompiler asserts from the CLI what the Go API +// could already do: a compiler option the user set changes the document. The +// control run pins that the difference is the flag and not the spec. +func TestRun_CompilerOptionReachesTheCompiler(t *testing.T) { + t.Parallel() + spec := writeFile(t, "spec.yaml", groupingSpec) + + assert.Equal(t, "zoo", groupNameOf(t, spec), "the default groups by tag") + assert.Equal(t, "a", groupNameOf(t, spec, "-opt", "grouping=path-prefix"), + "the compiler option must reach the compiler") +} + +// groupNameOf compiles spec with args and returns its first operation group's +// source name. +func groupNameOf(t *testing.T, spec string, args ...string) string { + t.Helper() + var stdout, stderr bytes.Buffer + code := run(append([]string{"compile", spec}, args...), &stdout, &stderr) + require.Equal(t, 0, code, "stderr: %s", stderr.String()) + + var doc ir.Document + require.NoError(t, json.Unmarshal(stdout.Bytes(), &doc)) + require.Len(t, doc.Services, 1) + require.NotEmpty(t, doc.Services[0].Groups) + return doc.Services[0].Groups[0].Name.Source +} + +// TestRun_OverlayOptionReachesTheCompiler covers the option whose value is a +// file. A compiler reads no files of its own, so the whole chain has to work for +// this to compile anything: the CLI collects the path, the engine loads it, and +// the compiler decodes the bytes into its own overlay type. +func TestRun_OverlayOptionReachesTheCompiler(t *testing.T) { + t.Parallel() + dir := t.TempDir() + spec := filepath.Join(dir, "spec.yaml") + require.NoError(t, os.WriteFile(spec, []byte(testspec.Tiny), 0o644)) + overlay := filepath.Join(dir, "patch.yaml") + require.NoError(t, os.WriteFile(overlay, []byte(`overlay: 1.0.0 +info: {title: Patch, version: "1"} +actions: + - target: $.info + update: {title: Patched} +`), 0o644)) + + var stdout, stderr bytes.Buffer + code := run([]string{"compile", spec, "--opt", "overlay=" + overlay}, &stdout, &stderr) + require.Equal(t, 0, code, "stderr: %s", stderr.String()) + + var doc ir.Document + require.NoError(t, json.Unmarshal(stdout.Bytes(), &doc)) + assert.Equal(t, "Patched", doc.Name, "the overlay must have been applied") + assert.Len(t, doc.Sources, 2, "an applied overlay is a second source") +} + +func TestRun_BadCompilerOptionIsRefused(t *testing.T) { + t.Parallel() + spec := writeFile(t, "spec.yaml", testspec.Tiny) + tests := []struct { + name, arg, wantErr string + }{ + {"malformed pair", "grouping", "want key=value"}, + {"unknown name", "gruoping=tags", `unknown option "gruoping"`}, + {"unusable value", "grouping=alphabetical", `want "tags" or "path-prefix"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + var stdout, stderr bytes.Buffer + code := run([]string{"compile", spec, "--opt", tc.arg}, &stdout, &stderr) + assert.Equal(t, 2, code) + assert.Contains(t, stderr.String(), tc.wantErr) + assert.Empty(t, stdout.String(), "a refused option must write no document") + }) + } +} + // unsortedSpec declares its schemas out of alphabetical order and its paths in // an order of their own, so a document compiled from it exercises both halves // of the determinism invariant: maps emitted in sorted-key order and slices in diff --git a/cmd/morphic/edgecases_test.go b/cmd/morphic/edgecases_test.go index 208c542..55fe9f1 100644 --- a/cmd/morphic/edgecases_test.go +++ b/cmd/morphic/edgecases_test.go @@ -89,6 +89,12 @@ func (nilDocCompiler) Formats() []compilers.SourceFormat { return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} } +func (nilDocCompiler) Detect(compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + return compilers.SourceFormat{Name: "openapi", Version: "3.1"}, nil, true +} + +func (nilDocCompiler) DecodeOptions(compilers.OptionSet) (any, error) { return nil, nil } + func (nilDocCompiler) Compile(context.Context, []compilers.Source, compilers.Options) (*ir.Document, []ir.Diagnostic, error) { return nil, []ir.Diagnostic{{ Severity: ir.SeverityError, diff --git a/cmd/morphic/options.go b/cmd/morphic/options.go new file mode 100644 index 0000000..e00c2c0 --- /dev/null +++ b/cmd/morphic/options.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "maps" + "slices" + "strings" +) + +// settingFlag collects the repeated `-opt key=value` settings that configure +// whichever compiler the spec turns out to need. +// +// It carries the pairs verbatim rather than parsing them, because the CLI does +// not know which compiler will read them: the names, the accepted values and +// what counts as a file path are the compiler's own, and a CLI that validated +// any of them would be holding a second copy of a vocabulary it cannot see +// change. What is checked here is the shape every compiler's settings share — +// that a pair has both halves, and that no key was given twice. +type settingFlag map[string]string + +// String renders the settings as they were typed, sorted so the rendering does +// not depend on map order. The flag package calls it on a zero value to decide +// whether to print a default, so a nil map must render as the empty string. +func (s settingFlag) String() string { + pairs := make([]string, 0, len(s)) + for _, key := range slices.Sorted(maps.Keys(s)) { + pairs = append(pairs, key+"="+s[key]) + } + return strings.Join(pairs, " ") +} + +// Set records one key=value pair. A repeated key is refused rather than +// overwritten: one of the two values would take effect with nothing to say +// which, and a user who typed both meant something by each. +func (s settingFlag) Set(raw string) error { + if s == nil { + return fmt.Errorf("no option set to write %q into", raw) + } + key, value, ok := strings.Cut(raw, "=") + if !ok { + return fmt.Errorf("want key=value, got %q", raw) + } + if key == "" { + return fmt.Errorf("empty option name in %q", raw) + } + if _, repeated := s[key]; repeated { + return fmt.Errorf("option %q set more than once", key) + } + s[key] = value + return nil +} diff --git a/cmd/morphic/options_test.go b/cmd/morphic/options_test.go new file mode 100644 index 0000000..0dc2b3a --- /dev/null +++ b/cmd/morphic/options_test.go @@ -0,0 +1,64 @@ +package main + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestSettingFlag_SetCollectsPairs(t *testing.T) { + t.Parallel() + got := settingFlag{} + require.NoError(t, got.Set("grouping=path-prefix")) + require.NoError(t, got.Set("overlay=patch.yaml")) + // A value may carry the separator; only the first one splits the pair. + require.NoError(t, got.Set("note=a=b")) + // An empty value is a value, not an absent setting: the compiler decides + // whether it can use one. + require.NoError(t, got.Set("quiet=")) + + assert.Equal(t, settingFlag{ + "grouping": "path-prefix", + "overlay": "patch.yaml", + "note": "a=b", + "quiet": "", + }, got) + assert.Equal(t, "grouping=path-prefix note=a=b overlay=patch.yaml quiet=", got.String()) +} + +func TestSettingFlag_StringOfNilRendersEmpty(t *testing.T) { + t.Parallel() + // The flag package renders a zero value to decide whether to print a + // default, so this is reached before any -opt is typed. + var unset settingFlag + assert.Empty(t, unset.String()) +} + +func TestSettingFlag_SetRefusals(t *testing.T) { + t.Parallel() + tests := []struct { + name, raw, wantErr string + }{ + {"no separator", "grouping", `want key=value, got "grouping"`}, + {"empty name", "=tags", `empty option name in "=tags"`}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + err := settingFlag{}.Set(tc.raw) + require.Error(t, err) + assert.EqualError(t, err, tc.wantErr) + }) + } + + repeated := settingFlag{} + require.NoError(t, repeated.Set("grouping=tags")) + err := repeated.Set("grouping=path-prefix") + require.Error(t, err, "one of the two values would win with nothing to say which") + assert.EqualError(t, err, `option "grouping" set more than once`) + assert.Equal(t, "tags", repeated["grouping"], "the refused value must not land") + + var unset settingFlag + assert.Error(t, unset.Set("grouping=tags"), "a nil set must refuse rather than panic") +} diff --git a/cmd/morphic/testdata/compile-help.txt b/cmd/morphic/testdata/compile-help.txt index 366c398..b9266c7 100644 --- a/cmd/morphic/testdata/compile-help.txt +++ b/cmd/morphic/testdata/compile-help.txt @@ -9,6 +9,10 @@ written with -o is compact unless --pretty asks for the indented form. type node interned there, the coordinates interned beneath it, and the diagnostics stamped at it — instead of writing the document. +--opt passes a setting to the compiler the spec selects, which names and +validates its own options; morphic itself knows none of them. The OpenAPI +compiler's are listed in the README. + A -- argument ends flag parsing: every argument after it is an operand, even one that begins with a dash, which is how a spec file named like a flag is passed. @@ -20,6 +24,8 @@ flags: fail (exit 1) on diagnostics at or above this severity: error|warning (default "error") -o string write IR JSON to this file instead of stdout + -opt key=value + set one key=value option on the compiler the spec selects (repeatable) -pretty indent the IR JSON -o writes; stdout is indented either way -skip-validate diff --git a/cmd/morphic/testdata/validate-help.txt b/cmd/morphic/testdata/validate-help.txt index 90c6e72..5465053 100644 --- a/cmd/morphic/testdata/validate-help.txt +++ b/cmd/morphic/testdata/validate-help.txt @@ -13,8 +13,13 @@ The diagnostics and the exit code are compile's: 0 when nothing reaches --skip-validate names the referential-integrity pass, not this command: it drops that pass's diagnostics and keeps the compiler's. +--opt is compile's, and takes the same settings: a spec that needs one to +compile needs it here too, or what is checked is not what would be built. + flags: -fail-on string fail (exit 1) on diagnostics at or above this severity: error|warning (default "error") + -opt key=value + set one key=value option on the compiler the spec selects (repeatable) -skip-validate skip the referential-integrity validate pass diff --git a/cmd/morphic/validate.go b/cmd/morphic/validate.go index 99d6aae..34215ac 100644 --- a/cmd/morphic/validate.go +++ b/cmd/morphic/validate.go @@ -20,7 +20,9 @@ func newValidateCommand() command { "--fail-on, 1 when something does or the spec lowered to no document at all,\n" + "2 for a misuse or an I/O error.\n\n" + "--skip-validate names the referential-integrity pass, not this command: it\n" + - "drops that pass's diagnostics and keeps the compiler's.", + "drops that pass's diagnostics and keeps the compiler's.\n\n" + + "--opt is compile's, and takes the same settings: a spec that needs one to\n" + + "compile needs it here too, or what is checked is not what would be built.", printFlags: func(w io.Writer) { fs, _ := newValidateFlags() fs.SetOutput(w) diff --git a/cmd/morphic/validate_test.go b/cmd/morphic/validate_test.go index 3c614e9..3c50fb3 100644 --- a/cmd/morphic/validate_test.go +++ b/cmd/morphic/validate_test.go @@ -226,3 +226,32 @@ func TestRun_ValidateNilDocumentReturnsOne(t *testing.T) { assert.Empty(t, stdout.String(), "validate must write nothing to stdout") assert.Contains(t, stderr.String(), "openapi/unsupported-version") } + +// TestValidate_CompilerOptionReachesThePipeline pins that validate configures +// the compiler the way compile does. 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 — otherwise what validate checks is not what compile +// would build, and the gate passes a spec the build then rejects. +// +// The overlay targets nothing, so the failure can only be the overlay's doing: +// the same spec validates clean without it, which the first assertion holds. +func TestValidate_CompilerOptionReachesThePipeline(t *testing.T) { + t.Parallel() + spec := writeFile(t, "spec.yaml", testspec.Tiny) + overlay := writeFile(t, "overlay.yaml", "overlay: 1.0.0\n"+ + "info: {title: O, version: \"1\"}\n"+ + "actions:\n - target: $.nonexistent\n update: {x: y}\n") + + var cleanOut, cleanErr bytes.Buffer + require.Equal(t, 0, run([]string{"validate", spec}, &cleanOut, &cleanErr), + "the spec must validate clean on its own, or the assertion below proves nothing: %s", + cleanErr.String()) + + var stdout, stderr bytes.Buffer + code := run([]string{"validate", spec, "--opt", "overlay=" + overlay}, &stdout, &stderr) + + assert.Equal(t, 1, code, "stderr: %s", stderr.String()) + assert.Contains(t, stderr.String(), "openapi/overlay-failed", + "the overlay must have been applied, which only --opt can have done") + assert.Empty(t, stdout.String(), "validate writes no document") +} diff --git a/compilers/compilers.go b/compilers/compilers.go index f0078e2..37e5755 100644 --- a/compilers/compilers.go +++ b/compilers/compilers.go @@ -33,11 +33,37 @@ type Options struct { FormatOptions any } +// OptionSet is one compile's configuration as text: settings named in the +// compiler's own option vocabulary, which DecodeOptions turns into that +// compiler's FormatOptions value. +// +// It exists so a caller can configure a compiler it does not import. The CLI +// collects key=value pairs without knowing which compiler will read them, and +// the compiler names and validates every key, so no layer above holds a list of +// another format's options. +type OptionSet struct { + // Settings maps an option name to its textual value. A nil or empty map asks + // for defaults. + Settings map[string]string + // ReadFile loads a file a setting names, e.g. an overlay document. A + // compiler does no file I/O of its own — Source is the whole of its input, + // which is what keeps compilation pure and reentrant — so the caller supplies + // the reader and the read stays the caller's. A nil ReadFile means no setting + // may name a file. + ReadFile func(name string) ([]byte, error) +} + // Compiler lowers source documents into the IR. Implementations must be pure: // no package-level mutable state, no writes to stderr; spec problems are // returned as ir.Diagnostic values and the error return is reserved for // I/O-level and programmer errors. // +// Detect and DecodeOptions are what keep the layers above format-agnostic: a +// compiler says what its own input looks like and what its own options are +// called, so registering one is the whole of adding a format. Both are required +// rather than optional interfaces on purpose — a compiler that answered neither +// would be registered and unreachable, which is a hole no caller can see. +// // A compiler reports through the returned slice. It may also store the same // findings on the Document it returns — that copy is what the persisted IR JSON // carries — but nothing obliges it to, and one that fills both must fill them @@ -45,6 +71,29 @@ type Options struct { // unions them, as the engine does, rather than take one for the whole set. type Compiler interface { Formats() []SourceFormat + // Detect reports the format src declares, and whether this compiler + // recognizes it at all. Recognition is not support: a compiler may name a + // format it does not serve — a version it has yet to implement — so that the + // caller can say so rather than report the source as unrecognized. + // + // An ok answer must name a format. Recognizing a source is knowing what it + // is, so the zero format with ok true is no answer, and Registry.Detect + // passes over a compiler that gives one rather than let it end the search. + // + // diags is what this compiler can say about a source it declines, and is read + // only when ok is false. Bytes of another format are ordinary input here, so + // declining them is silent: a compiler that reported every source it did not + // take would bury the one report that matters under one per registered + // format. It is for the narrower case where the source is recognizably this + // compiler's own and cannot be read — a malformed document in its own + // serialization — which no other compiler is in a position to say, and which + // the caller would otherwise have to report as unrecognized. + Detect(src Source) (format SourceFormat, diags []ir.Diagnostic, ok bool) + // DecodeOptions turns textual settings into the value this compiler expects + // in Options.FormatOptions. An empty set yields defaults. An unknown key, an + // unusable value, or a file that cannot be read is an error — a setting that + // is silently ignored leaves the caller believing they configured something. + DecodeOptions(set OptionSet) (any, error) Compile(ctx context.Context, sources []Source, opts Options) (*ir.Document, []ir.Diagnostic, error) } @@ -53,6 +102,7 @@ type Compiler interface { // composes its registry explicitly. The zero value is a usable empty registry. type Registry struct { byFormat map[SourceFormat]Compiler + ordered []Compiler } // NewRegistry returns an empty registry. @@ -86,9 +136,54 @@ func (r *Registry) Register(c Compiler) error { for _, format := range formats { r.byFormat[format] = c } + r.ordered = append(r.ordered, c) return nil } +// Detect asks each registered compiler in turn to recognize src, and returns +// the first that does: the compiler registered for the format it named, that +// format, and whether such a compiler exists. Nothing recognizing src is +// reported as the zero format, which is what tells "no compiler takes these +// bytes" from "this format is recognized but unsupported". +// +// diags collects what the declining compilers had to say, in the order they +// were asked, and is meaningful only when no compiler took src. A compiler that +// recognizes src ends the search, so nothing after it is asked and nothing it +// might have said is collected — the answer to "who takes this" makes any +// account of why others did not moot. +// +// The order is registration order, because it is the only order a caller +// controls — the variadic that composes the registry fixes it — and a map's +// would make two compilers that both claim a source resolve differently from +// run to run. +func (r *Registry) Detect(src Source) (Compiler, SourceFormat, []ir.Diagnostic, bool) { + if len(src.Data) == 0 { + return nil, SourceFormat{}, nil, false + } + + var declined []ir.Diagnostic + for _, c := range r.ordered { + format, diags, ok := c.Detect(src) + if !ok { + declined = append(declined, diags...) + continue + } + // A compiler that recognizes a source names the format it recognized. One + // that claims a source and names nothing has answered no question, and + // letting it end the search would hide every compiler registered after it + // — a source another compiler would have taken becomes unrecognized, with + // nothing to say which compiler swallowed it. Its diags are not collected: + // the contract reads them only when a compiler declines, and this one did + // not say it declined. + if format == (SourceFormat{}) { + continue + } + owner, registered := r.Lookup(format) + return owner, format, nil, registered + } + return nil, SourceFormat{}, declined, false +} + // isNilCompiler reports whether c is unsafe to call: an untyped nil interface or // a typed nil pointer stored in one. // diff --git a/compilers/compilers_test.go b/compilers/compilers_test.go index 801c0bd..9c471cb 100644 --- a/compilers/compilers_test.go +++ b/compilers/compilers_test.go @@ -1,6 +1,7 @@ package compilers_test import ( + "bytes" "context" "testing" @@ -11,11 +12,32 @@ import ( "github.com/dexpace/morphic/ir" ) -// stubCompiler registers under fixed formats and returns an empty document. -type stubCompiler struct{ formats []compilers.SourceFormat } +// stubCompiler registers under fixed formats and returns an empty document. It +// recognizes a source whose bytes contain its marker, so a registry holding two +// of them can be asked which one claims a given source. +type stubCompiler struct { + formats []compilers.SourceFormat + marker string + detects compilers.SourceFormat + // declines is what this compiler says when it does not take a source, which + // the registry is meant to carry out for the caller to report. + declines []ir.Diagnostic +} func (s *stubCompiler) Formats() []compilers.SourceFormat { return s.formats } +func (s *stubCompiler) Detect(src compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + if s.marker == "" || !bytes.Contains(src.Data, []byte(s.marker)) { + return compilers.SourceFormat{}, s.declines, false + } + if s.detects != (compilers.SourceFormat{}) { + return s.detects, nil, true + } + return s.formats[0], nil, true +} + +func (s *stubCompiler) DecodeOptions(compilers.OptionSet) (any, error) { return nil, nil } + func (s *stubCompiler) Compile(_ context.Context, _ []compilers.Source, _ compilers.Options) (*ir.Document, []ir.Diagnostic, error) { return &ir.Document{IRVersion: ir.IRVersion}, nil, nil } @@ -26,7 +48,7 @@ func TestRegistry_RegisterAndLookup(t *testing.T) { oa := &stubCompiler{formats: []compilers.SourceFormat{ {Name: "openapi", Version: "3.0"}, {Name: "openapi", Version: "3.1"}, - }} + }, marker: "openapi"} require.NoError(t, reg.Register(oa)) got, ok := reg.Lookup(compilers.SourceFormat{Name: "openapi", Version: "3.1"}) @@ -99,3 +121,165 @@ func TestSourceFormat_String(t *testing.T) { t.Parallel() assert.Equal(t, "openapi@3.1", compilers.SourceFormat{Name: "openapi", Version: "3.1"}.String()) } + +// TestRegistry_DetectAsksEachCompilerInRegistrationOrder is the seam that keeps +// format knowledge out of the layers above: the registry asks, the compilers +// answer, and adding a format is a registration rather than an edit somewhere +// else. Registration order is the tie-break, so the same source resolves the +// same way on every run. +func TestRegistry_DetectAsksEachCompilerInRegistrationOrder(t *testing.T) { + t.Parallel() + first := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "shared", + } + second := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "beta", Version: "2"}}, + marker: "shared", + } + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(first)) + require.NoError(t, reg.Register(second)) + + got, format, _, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("shared bytes")}) + require.True(t, ok) + assert.Same(t, compilers.Compiler(first), got, "the earlier registration wins") + assert.Equal(t, compilers.SourceFormat{Name: "alpha", Version: "1"}, format) + + _, format, _, ok = reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("nobody claims this")}) + assert.False(t, ok) + assert.Equal(t, compilers.SourceFormat{}, format, "the zero format means unrecognized") +} + +// TestRegistry_DetectReportsRecognizedButUnregistered separates the two ways a +// source can go uncompiled: nothing recognized it, or something did and named a +// format the registry does not carry. A caller that could not tell them apart +// would report a known spec dialect as an unreadable file. +func TestRegistry_DetectReportsRecognizedButUnregistered(t *testing.T) { + t.Parallel() + front := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "alpha", + detects: compilers.SourceFormat{Name: "alpha", Version: "9"}, + } + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(front)) + + got, format, _, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("alpha")}) + assert.False(t, ok, "no compiler is registered for alpha@9") + assert.Nil(t, got) + assert.Equal(t, compilers.SourceFormat{Name: "alpha", Version: "9"}, format) +} + +func TestRegistry_DetectEmptySource(t *testing.T) { + t.Parallel() + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(&stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "alpha", + })) + + _, _, _, ok := reg.Detect(compilers.Source{Path: "empty.txt"}) + assert.False(t, ok, "no bytes declare no format") +} + +// TestRegistry_DetectSkipsCompilersThatDecline pins that a compiler declining a +// source does not end the search. +func TestRegistry_DetectSkipsCompilersThatDecline(t *testing.T) { + t.Parallel() + declines := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "alpha", + } + claims := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "beta", Version: "2"}}, + marker: "beta", + } + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(declines)) + require.NoError(t, reg.Register(claims)) + + got, format, _, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("beta")}) + require.True(t, ok) + assert.Same(t, compilers.Compiler(claims), got) + assert.Equal(t, compilers.SourceFormat{Name: "beta", Version: "2"}, format) +} + +// TestRegistry_DetectCarriesWhatDecliningCompilersSaid pins the channel the +// contract opened: a compiler that declines a source it recognizes as its own +// and broken has something to report, and the registry is what carries it out to +// a caller that would otherwise have only "nobody claimed these bytes". +func TestRegistry_DetectCarriesWhatDecliningCompilersSaid(t *testing.T) { + t.Parallel() + said := ir.NewDiagnostic(ir.SeverityError, "alpha/broken", "malformed alpha", + ir.Provenance{Source: ir.NoSource}) + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(&stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "alpha", + declines: []ir.Diagnostic{said}, + })) + + _, _, diags, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("not for you")}) + + require.False(t, ok) + assert.Equal(t, []ir.Diagnostic{said}, diags) +} + +// TestRegistry_DetectDropsDeclinesOnceClaimed pins the other half: a compiler +// that takes the source ends the search, so an earlier decliner's account of why +// it passed is moot. Carrying it anyway would attach a complaint to a compile +// that went on to succeed. +func TestRegistry_DetectDropsDeclinesOnceClaimed(t *testing.T) { + t.Parallel() + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(&stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + marker: "alpha", + declines: []ir.Diagnostic{ir.NewDiagnostic(ir.SeverityError, "alpha/broken", "malformed alpha", + ir.Provenance{Source: ir.NoSource})}, + })) + require.NoError(t, reg.Register(&stubCompiler{ + formats: []compilers.SourceFormat{{Name: "beta", Version: "2"}}, + marker: "beta", + })) + + _, format, diags, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("beta")}) + + require.True(t, ok) + assert.Equal(t, compilers.SourceFormat{Name: "beta", Version: "2"}, format) + assert.Empty(t, diags, "the source found a compiler, so nothing declined is worth saying") +} + +// claimsNothing recognizes every source and names no format, which the contract +// does not allow. It exists to pin what the registry does with a compiler that +// breaks it. +type claimsNothing struct{ stubCompiler } + +func (claimsNothing) Detect(compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + return compilers.SourceFormat{}, nil, true +} + +// TestRegistry_DetectSkipsACompilerThatClaimsWithoutNaming pins that a compiler +// answering "mine" while naming no format does not end the search. Ending it +// there would make a source the next compiler would have taken come back +// unrecognized, with nothing in the output naming the compiler that swallowed it. +func TestRegistry_DetectSkipsACompilerThatClaimsWithoutNaming(t *testing.T) { + t.Parallel() + broken := &claimsNothing{stubCompiler{ + formats: []compilers.SourceFormat{{Name: "alpha", Version: "1"}}, + }} + claims := &stubCompiler{ + formats: []compilers.SourceFormat{{Name: "beta", Version: "2"}}, + marker: "beta", + } + reg := compilers.NewRegistry() + require.NoError(t, reg.Register(broken)) + require.NoError(t, reg.Register(claims)) + + got, format, _, ok := reg.Detect(compilers.Source{Path: "s.txt", Data: []byte("beta")}) + + require.True(t, ok, "the compiler after the broken one must still be asked") + assert.Same(t, compilers.Compiler(claims), got) + assert.Equal(t, compilers.SourceFormat{Name: "beta", Version: "2"}, format) +} diff --git a/compilers/openapi/detect.go b/compilers/openapi/detect.go new file mode 100644 index 0000000..b21e8b9 --- /dev/null +++ b/compilers/openapi/detect.go @@ -0,0 +1,215 @@ +package openapi + +import ( + "bytes" + "encoding/json" + + yaml "gopkg.in/yaml.v3" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +// maxSniffBytes bounds the bytes Detect parses. Detection reads two top-level +// keys, and 64 KiB reaches them in any document a person wrote, so the cost of +// asking stays flat while spec size does not: a full parse of a 10 MB document +// costs hundreds of milliseconds before the compiler's own parse begins. +const maxSniffBytes = 64 << 10 + +// maxSniffEntries bounds the top-level entries read from a flow-style prefix. +// The keys being looked for are declared among a document's first few, and a +// prefix full of nothing else is not one this compiler will take. +const maxSniffEntries = 512 + +// sniffProbe holds the two discriminating top-level keys. Which one is present +// is the whole of the format question: an OpenAPI 3.x document declares +// `openapi`, a Swagger 2.0 document declares `swagger`. +type sniffProbe struct { + OpenAPI string `yaml:"openapi"` + Swagger string `yaml:"swagger"` +} + +// Detect implements compilers.Compiler. It reports the dialect src declares, +// keyed by the major.minor prefix of the version string. +// +// It names swagger@2.0 as well, which this compiler does not serve: a Swagger +// document is recognizably an API spec, and reporting it as one lets the caller +// say the format is unsupported rather than that the file is unreadable. The +// path is not consulted — an OpenAPI document is what it declares itself to be, +// under any extension. +// +// Bytes that do not parse are declined silently unless they declare one of the +// discriminating keys, in which case the parse error is reported: a source that +// says `openapi:` and will not parse is this compiler's own and broken, which +// nothing else is in a position to say. Bytes that declare neither key are +// another format's business, and a YAML parser's complaint about them describes +// only the parser that was wrong to be asked. +func (*Compiler) Detect(src compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + probe, err := sniff(src.Data) + switch { + case probe.OpenAPI != "": + return compilers.SourceFormat{Name: "openapi", Version: majorMinor(probe.OpenAPI)}, nil, true + case probe.Swagger != "": + return compilers.SourceFormat{Name: "swagger", Version: majorMinor(probe.Swagger)}, nil, true + case err != nil && declaresProbeKey(src.Data): + // NoSource, not source 0: detection runs before any document exists, so + // there is no source table for a provenance to index into. + return compilers.SourceFormat{}, []ir.Diagnostic{diag.Newf( + ir.SeverityError, diag.UndecodableSource, ir.Provenance{Source: ir.NoSource}, + "source declares an OpenAPI or Swagger key and does not parse: %v", err)}, false + default: + return compilers.SourceFormat{}, nil, false + } +} + +// declaresProbeKey reports whether data names one of the discriminating keys as +// a top-level key. It is what separates a source of this compiler's own that +// will not parse from one of another format that was never its business: a +// parse failure alone says only "not YAML", which a Protobuf or Smithy source +// is not either. +// +// Only the bounded prefix is read, for the reason sniff bounds its own reads. +func declaresProbeKey(data []byte) bool { + if len(data) > maxSniffBytes { + data = data[:maxSniffBytes] + } + return declaresKey(data, "openapi") || declaresKey(data, "swagger") +} + +// declaresKey reports whether data names key at the top level, in either style: +// unquoted at the start of a line for block style, or quoted for flow style, +// which is how JSON writes every key. +// +// Both spellings require the colon that makes it a key. Without it, a document +// of another format that merely mentions the word — in a comment, or as a value +// — would be claimed as this compiler's and reported under its parse error. +func declaresKey(data []byte, key string) bool { + block := []byte(key + ":") + if bytes.HasPrefix(data, block) || bytes.Contains(data, []byte("\n"+key+":")) { + return true + } + return followedByColon(data, []byte(`"`+key+`"`)) +} + +// followedByColon reports whether name occurs in data followed by a colon, +// ignoring the whitespace a flow mapping may put between them. +func followedByColon(data, name []byte) bool { + for i := 0; ; { + j := bytes.Index(data[i:], name) + if j < 0 { + return false + } + rest := bytes.TrimLeft(data[i+j+len(name):], " \t\r\n") + if len(rest) > 0 && rest[0] == ':' { + return true + } + i += j + len(name) + } +} + +// sniff reads the discriminating keys out of at most maxSniffBytes bytes, and +// returns the zero probe and the parser's error for anything it cannot read. +// Whether that error is worth reporting is Detect's question, not this one's: +// here it is only the record of what happened. +// +// A document within the cap is decoded whole and exactly. A larger one is +// decoded from a prefix, which cannot simply be cut: flow style — JSON is the +// common case — is one token stream with no line structure, so its entries are +// streamed instead, and block style is cut at its last complete line. +func sniff(data []byte) (sniffProbe, error) { + if len(data) <= maxSniffBytes { + return decodeYAML(data) + } + prefix := data[:maxSniffBytes] + if probe, ok := decodeFlowPrefix(prefix); ok { + return probe, nil + } + return decodeYAML(wholeLines(prefix)) +} + +// decodeYAML reads the probe keys from a complete YAML (or JSON, its subset) +// document. +func decodeYAML(data []byte) (sniffProbe, error) { + var probe sniffProbe + if err := yaml.Unmarshal(data, &probe); err != nil { + return sniffProbe{}, err + } + return probe, nil +} + +// decodeFlowPrefix reads the top-level entries of a prefix that opens a flow +// mapping, and reports whether it was one. The JSON decoder is used because it +// streams: a prefix cut mid-document still yields every entry it completed, +// where decoding those same bytes whole reports only that they end early. +func decodeFlowPrefix(prefix []byte) (sniffProbe, bool) { + dec := json.NewDecoder(bytes.NewReader(prefix)) + tok, err := dec.Token() + if err != nil || tok != json.Delim('{') { + return sniffProbe{}, false + } + + var probe sniffProbe + for range maxSniffEntries { + key, err := dec.Token() + if err != nil { + break + } + var value json.RawMessage + if err := dec.Decode(&value); err != nil { + break + } + recordEntry(&probe, key, value) + } + return probe, true +} + +// recordEntry stores value under probe's field for key. key is compared as read +// rather than asserted to a string: the closing delimiter of the mapping +// arrives here too, and it matches neither name. +func recordEntry(probe *sniffProbe, key json.Token, value json.RawMessage) { + switch key { + case "openapi": + probe.OpenAPI = jsonString(value) + case "swagger": + probe.Swagger = jsonString(value) + } +} + +// jsonString returns value as a string, or "" for any other shape. A version +// that is not a string does not declare a dialect. +func jsonString(value json.RawMessage) string { + var out string + if err := json.Unmarshal(value, &out); err != nil { + return "" + } + return out +} + +// wholeLines returns prefix up to and including its last newline, so a block +// document is cut between entries rather than inside one. A prefix with no +// newline in it is returned as it is; there is no better cut to make. +func wholeLines(prefix []byte) []byte { + if i := bytes.LastIndexByte(prefix, '\n'); i >= 0 { + return prefix[:i+1] + } + return prefix +} + +// majorMinor returns the "major.minor" prefix of a dotted version string, +// e.g. "3.1.0" → "3.1". Strings with fewer than two dots — a bare major +// version, or a version already in major.minor form — are returned unchanged. +func majorMinor(version string) string { + firstDot := -1 + for i := range len(version) { + if version[i] != '.' { + continue + } + if firstDot < 0 { + firstDot = i + continue + } + return version[:i] + } + return version +} diff --git a/compilers/openapi/detect_test.go b/compilers/openapi/detect_test.go new file mode 100644 index 0000000..c2bc306 --- /dev/null +++ b/compilers/openapi/detect_test.go @@ -0,0 +1,226 @@ +package openapi + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/diag" + "github.com/dexpace/morphic/ir" +) + +func TestDetect_Formats(t *testing.T) { + t.Parallel() + cases := []struct { + name, path, src string + want compilers.SourceFormat + wantOK bool + // wantCode is the codes of the diagnostics a decline carries. Empty for + // every source this compiler recognizes, and for every source that is + // another format's business. + wantCode []string + }{ + {"openapi 3.1 yaml", "api.yaml", "openapi: 3.1.0\ninfo: {}\n", + compilers.SourceFormat{Name: "openapi", Version: "3.1"}, true, nil}, + {"openapi 3.0 json", "api.json", `{"openapi": "3.0.3"}`, + compilers.SourceFormat{Name: "openapi", Version: "3.0"}, true, nil}, + {"openapi 3.2 yaml", "api.yaml", "openapi: 3.2.0\ninfo: {}\n", + compilers.SourceFormat{Name: "openapi", Version: "3.2"}, true, nil}, + // A version already in major.minor form (single dot) exercises + // majorMinor's unchanged-passthrough return. + {"openapi major.minor only", "api.yaml", "openapi: \"3.1\"\n", + compilers.SourceFormat{Name: "openapi", Version: "3.1"}, true, nil}, + // A bare-major version (no dot) also reaches the passthrough return. + {"openapi bare major", "api.yaml", "openapi: \"4\"\n", + compilers.SourceFormat{Name: "openapi", Version: "4"}, true, nil}, + // Recognized, and deliberately not a format Formats reports: naming it + // lets the caller say "unsupported" rather than "unreadable". + {"swagger", "api.yaml", "swagger: \"2.0\"\n", + compilers.SourceFormat{Name: "swagger", Version: "2.0"}, true, nil}, + + // Everything below is another format's input, and none of it is an error + // here: three of the five planned compilers take bytes that are not YAML. + {"protobuf", "svc.proto", "syntax = \"proto3\";\nservice S { rpc Get (Q) returns (A); }\n", + compilers.SourceFormat{}, false, nil}, + {"typespec", "main.tsp", "import \"@typespec/http\";\nmodel Pet { name: string; }\n", + compilers.SourceFormat{}, false, nil}, + {"graphql", "s.graphql", "type Query {\n pet(id: ID!): Pet\n}\n", + compilers.SourceFormat{}, false, nil}, + // Valid YAML that declares neither key: whether a source parses is not + // the question detection answers. + {"graphql that parses as yaml", "s.graphql", "type Query { a: String }\n", + compilers.SourceFormat{}, false, nil}, + {"yaml that is no spec", "junk.yaml", "hello: world\n", + compilers.SourceFormat{}, false, nil}, + // Declares an openapi key and will not parse: this compiler's own source, + // broken, which nothing else is in a position to say. + {"unparseable yaml", "api.yaml", "openapi: [unterminated\n", + compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, + {"unparseable json", "api.json", `{"openapi": "3.1.0", "info": {`, + compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, + // Broken, and never this compiler's: the key it names is a value, not a + // key, so the parse error describes a parser that was wrong to be asked. + {"unparseable, key only mentioned", "svc.proto", "syntax = \"openapi\";\n{[", + compilers.SourceFormat{}, false, nil}, + // Past the sniff cap and still this compiler's: the key search reads the + // same bounded prefix the decode did, so a document too large to parse in + // full is still recognized as broken rather than as somebody else's. + {"unparseable past the cap", "api.yaml", + padTo("openapi: [unterminated\n", "filler: x\n"), + compilers.SourceFormat{}, false, []string{diag.UndecodableSource}}, + // Declares the key only past the cap, on a prefix that does not parse. The + // key search reads the same bounded prefix the decode did and so does not + // see it either; claiming the source would assert something about bytes + // detection never read. + {"key past the cap on an unparseable prefix", "api.yaml", + padTo("bad: [unterminated\n", "filler: x\n") + "openapi: 3.1.0\n", + compilers.SourceFormat{}, false, nil}, + {"empty", "empty.yaml", "", compilers.SourceFormat{}, false, nil}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, diags, ok := New().Detect(compilers.Source{Path: tc.path, Data: []byte(tc.src)}) + assert.Equal(t, tc.wantOK, ok) + assert.Equal(t, tc.want, got) + assert.Equal(t, tc.wantCode, codesOf(diags), + "a decline says something only when the source is recognizably this compiler's") + }) + } +} + +// padTo returns src grown past the sniff cap by appending filler, so sniff takes +// its bounded-prefix path rather than decoding the source whole. +func padTo(src, filler string) string { + var b strings.Builder + b.WriteString(src) + for b.Len() <= maxSniffBytes { + b.WriteString(filler) + } + return b.String() +} + +// TestSniff_BeyondTheCap pins what the bound buys and what it costs. A document +// larger than the cap is read from its first maxSniffBytes in whichever style it +// is written, and a declaration past that point is not seen — detection stays +// flat in document size rather than paying a full parse to read two keys. +func TestSniff_BeyondTheCap(t *testing.T) { + t.Parallel() + const filler = "# a line of padding that says nothing about the format\n" + cases := []struct { + name, src string + want sniffProbe + }{ + {"block yaml declaring first", + padTo("openapi: 3.1.0\n", filler), sniffProbe{OpenAPI: "3.1.0"}}, + {"block yaml declaring past the cap", + padTo("", filler) + "openapi: 3.1.0\n", sniffProbe{}}, + {"flow json declaring first", + `{"openapi":"3.1.0","x":"` + strings.Repeat("p", maxSniffBytes) + `"}`, + sniffProbe{OpenAPI: "3.1.0"}}, + {"flow json declaring past the cap", + `{"x":"` + strings.Repeat("p", maxSniffBytes) + `","openapi":"3.1.0"}`, + sniffProbe{}}, + {"flow json swagger first", + `{"swagger":"2.0","x":"` + strings.Repeat("p", maxSniffBytes) + `"}`, + sniffProbe{Swagger: "2.0"}}, + // Neither YAML nor JSON, and larger than the cap: the prefix is parsed, + // fails, and the answer is silence rather than a parser's complaint. + {"protobuf past the cap", + padTo("syntax = \"proto3\";\n", "message M { string a = 1; }\n"), sniffProbe{}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Greater(t, len(tc.src), maxSniffBytes, "the case must exceed the cap to test it") + probe, _ := sniff([]byte(tc.src)) + assert.Equal(t, tc.want, probe, + "the error is not asserted: a prefix of another format is unreadable here by design") + }) + } +} + +func TestDecodeFlowPrefix_ReadsWhatTheCutLeft(t *testing.T) { + t.Parallel() + cases := []struct { + name, prefix string + want sniffProbe + wantFlow bool + }{ + {"complete document", `{"openapi":"3.1.0","info":{"title":"T"}}`, + sniffProbe{OpenAPI: "3.1.0"}, true}, + {"cut inside a later value", `{"openapi":"3.1.0","info":{"title":"T`, + sniffProbe{OpenAPI: "3.1.0"}, true}, + {"cut inside a key", `{"openapi":"3.1.0","inf`, + sniffProbe{OpenAPI: "3.1.0"}, true}, + {"swagger", `{"swagger":"2.0","info":{}}`, sniffProbe{Swagger: "2.0"}, true}, + // A version that is not a string declares no dialect, and must not be + // read as one by accident. + {"non-string version", `{"openapi":3}`, sniffProbe{}, true}, + {"no flow mapping", "openapi: 3.1.0\n", sniffProbe{}, false}, + {"not even a token", "\x00", sniffProbe{}, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, flow := decodeFlowPrefix([]byte(tc.prefix)) + assert.Equal(t, tc.wantFlow, flow) + assert.Equal(t, tc.want, got) + }) + } +} + +// TestDecodeFlowPrefix_StopsAtTheEntryCap proves the walk is bounded by its own +// count and not only by the byte cap: a declaration after maxSniffEntries other +// entries is not read. +func TestDecodeFlowPrefix_StopsAtTheEntryCap(t *testing.T) { + t.Parallel() + var b strings.Builder + b.WriteByte('{') + for i := range maxSniffEntries + 1 { + if i > 0 { + b.WriteByte(',') + } + b.WriteString(`"k`) + b.WriteString(strings.Repeat("x", 3)) + b.WriteString(string(rune('a' + i%26))) + b.WriteString(strings.Repeat("y", i%7)) + b.WriteString(`":0`) + } + b.WriteString(`,"openapi":"3.1.0"}`) + + got, flow := decodeFlowPrefix([]byte(b.String())) + require.True(t, flow) + assert.Equal(t, sniffProbe{}, got, "the entry past the cap is not read") +} + +func TestWholeLines(t *testing.T) { + t.Parallel() + assert.Equal(t, "a\nb\n", string(wholeLines([]byte("a\nb\nc")))) + assert.Equal(t, "nolines", string(wholeLines([]byte("nolines"))), + "a prefix with no newline has no better cut to make") +} + +func TestMajorMinor(t *testing.T) { + t.Parallel() + assert.Equal(t, "3.1", majorMinor("3.1.0")) + assert.Equal(t, "3.1", majorMinor("3.1")) + assert.Equal(t, "4", majorMinor("4")) +} + +// codesOf reduces diagnostics to their codes, which is the whole of what the +// detection tests assert about them: the message carries a parser's wording and +// pinning it would test yaml.v3 rather than this package. +func codesOf(diags []ir.Diagnostic) []string { + if len(diags) == 0 { + return nil + } + codes := make([]string, 0, len(diags)) + for _, d := range diags { + codes = append(codes, d.Code) + } + return codes +} diff --git a/compilers/openapi/doc.go b/compilers/openapi/doc.go index f3764aa..96933bd 100644 --- a/compilers/openapi/doc.go +++ b/compilers/openapi/doc.go @@ -2,8 +2,10 @@ // implements compilers.Compiler. // // What is here is the compiler's public face and the assembly behind it: the -// Compiler, its Options, the document metadata, and the run that calls the -// lowerings in order and builds a Document out of what they return. +// Compiler, its Options and the vocabulary they answer to as text, the +// recognition of an OpenAPI document from its own bytes, the document metadata, +// and the run that calls the lowerings in order and builds a Document out of +// what they return. // // Parsing is delegated to github.com/speakeasy-api/openapi. Everything the // compiler itself decides — identity (pointer-derived IDs), hoisting, diff --git a/compilers/openapi/internal/diag/diag.go b/compilers/openapi/internal/diag/diag.go index 3e10899..a4d92b4 100644 --- a/compilers/openapi/internal/diag/diag.go +++ b/compilers/openapi/internal/diag/diag.go @@ -44,6 +44,13 @@ const ( // alias-expansion allowance is derived from, so the document is refused rather // than scanned against a bound computed from a count that stopped early. SourceTooLarge = "openapi/source-too-large" + // UndecodableSource reports a source that declares one of this compiler's + // discriminating keys and does not parse as YAML or JSON. It is reported from + // detection rather than from the compile, because a document that cannot be + // parsed never reaches one: without it the engine could only say no compiler + // recognized the source, which is wrong twice over — this compiler did + // recognize it, and the reason it declined is the parse error it holds. + UndecodableSource = "openapi/undecodable-source" // OverlayInvalid reports an overlay document that could not be parsed, or that // parsed but is not a valid Overlay — a missing version, no actions, an action // naming no target. Nothing is applied, so the compile refuses rather than diff --git a/compilers/openapi/internal/diag/diag_test.go b/compilers/openapi/internal/diag/diag_test.go index d4bdb9e..c4e3ce0 100644 --- a/compilers/openapi/internal/diag/diag_test.go +++ b/compilers/openapi/internal/diag/diag_test.go @@ -128,7 +128,7 @@ func TestHasError_Cases(t *testing.T) { func codes() []string { return []string{ diag.Validation, diag.UnsupportedVersion, diag.UnresolvedRef, diag.CyclicRef, - diag.CycleScanFailed, diag.SourceTooLarge, + diag.CycleScanFailed, diag.SourceTooLarge, diag.UndecodableSource, diag.OverlayInvalid, diag.OverlayFailed, diag.OverlayAction, diag.OverlayOriginIncomplete, diag.ValidationOnlyKeyword, diag.FalseSchema, diff --git a/compilers/openapi/options.go b/compilers/openapi/options.go index 35c6b81..3ba06e6 100644 --- a/compilers/openapi/options.go +++ b/compilers/openapi/options.go @@ -1,6 +1,15 @@ package openapi -import "github.com/dexpace/morphic/compilers/openapi/internal/lowering" +import ( + "fmt" + "maps" + "slices" + "strconv" + "strings" + + "github.com/dexpace/morphic/compilers" + "github.com/dexpace/morphic/compilers/openapi/internal/lowering" +) // GroupingStrategy selects how operations are grouped into OperationGroups. It // is the injectable-policy seam (architecture principle 6): grouping is inferred @@ -57,10 +66,9 @@ type Options struct { // The document arrives as bytes, like the spec itself, because a compiler // performs no file I/O — reading it is the caller's job, which is what keeps // compilation pure and reentrant. A programmatic caller sets this through -// engine.RunOptions.FormatOptions, which the engine forwards verbatim; no -// morphic subcommand surfaces it yet, and the CLI exposes no other member of -// this type either, so adding a flag for one is a change about the CLI's option -// surface rather than about overlays. +// engine.RunOptions.FormatOptions, which the engine forwards verbatim; a caller +// who has only text names the file with the "overlay" setting and the reader in +// compilers.OptionSet loads it, so the read is still the caller's. // // An applied overlay becomes a second entry in Document.Sources, and every // position it introduced or rewrote names that entry as its Provenance.Source. @@ -89,3 +97,113 @@ func (o Options) withDefaults() Options { } return o } + +// The option names Options answers to as text. They are this compiler's own +// vocabulary: a caller writes them without knowing which compiler reads them, +// and nothing above this package holds the list. +const ( + optGrouping = "grouping" + optAllowExternalRefs = "allow-external-refs" + optOverlay = "overlay" + optOverlayLax = "overlay-lax" +) + +// optionNames lists the vocabulary, sorted, for the error that reports a name +// outside it. Deriving the list from one place is what keeps the error honest +// as the vocabulary grows. +func optionNames() []string { + names := []string{optGrouping, optAllowExternalRefs, optOverlay, optOverlayLax} + slices.Sort(names) + return names +} + +// DecodeOptions implements compilers.Compiler: it turns textual settings into +// an Options value. Settings are read in sorted order so that a set with two +// bad values always reports the same one. +func (*Compiler) DecodeOptions(set compilers.OptionSet) (any, error) { + var decode optionDecode + for _, key := range slices.Sorted(maps.Keys(set.Settings)) { + if err := decode.set(key, set.Settings[key]); err != nil { + return nil, err + } + } + opts, err := decode.finish(set.ReadFile) + if err != nil { + return nil, err + } + return opts, nil +} + +// optionDecode is an Options under construction. The overlay is described by two +// settings — the document and how strictly to apply it — so it is assembled once +// every setting has been read, and neither order of the two changes the result. +type optionDecode struct { + opts Options + overlayPath string + lax bool + laxSet bool +} + +// set reads one setting. +func (d *optionDecode) set(key, value string) error { + switch key { + case optGrouping: + return decodeGrouping(&d.opts.Grouping, value) + case optAllowExternalRefs: + return decodeBool(&d.opts.AllowExternalRefs, key, value) + case optOverlay: + d.overlayPath = value + return nil + case optOverlayLax: + d.laxSet = true + return decodeBool(&d.lax, key, value) + default: + return fmt.Errorf("openapi: unknown option %q (known: %s)", + key, strings.Join(optionNames(), ", ")) + } +} + +// finish assembles the decoded Options, loading the overlay document a setting +// named through the caller's reader. +func (d *optionDecode) finish(readFile func(name string) ([]byte, error)) (Options, error) { + if d.overlayPath == "" { + if d.laxSet { + return Options{}, fmt.Errorf("openapi: option %q applies only with %q", + optOverlayLax, optOverlay) + } + return d.opts, nil + } + if readFile == nil { + return Options{}, fmt.Errorf("openapi: option %q names a file and the caller supplied no reader", + optOverlay) + } + data, err := readFile(d.overlayPath) + if err != nil { + return Options{}, fmt.Errorf("openapi: option %q: %w", optOverlay, err) + } + d.opts.Overlay = &Overlay{Path: d.overlayPath, Data: data, Lax: d.lax} + return d.opts, nil +} + +// decodeGrouping reads a grouping strategy by its own name, which is the same +// string the JSON form uses. +func decodeGrouping(out *GroupingStrategy, value string) error { + switch strategy := GroupingStrategy(value); strategy { + case GroupByTags, GroupByPathPrefix: + *out = strategy + return nil + default: + return fmt.Errorf("openapi: option %q: want %q or %q, got %q", + optGrouping, GroupByTags, GroupByPathPrefix, value) + } +} + +// decodeBool reads a boolean setting, spelled as strconv.ParseBool accepts it. +func decodeBool(out *bool, key, value string) error { + parsed, err := strconv.ParseBool(value) + if err != nil { + return fmt.Errorf("openapi: option %q: want a boolean, got %q", key, value) + } + *out = parsed + return nil +} diff --git a/compilers/openapi/options_test.go b/compilers/openapi/options_test.go index 8ef1222..fb347b8 100644 --- a/compilers/openapi/options_test.go +++ b/compilers/openapi/options_test.go @@ -2,6 +2,8 @@ package openapi import ( "context" + "errors" + "fmt" "testing" "github.com/stretchr/testify/assert" @@ -28,3 +30,124 @@ func TestParse_ExplicitOptions(t *testing.T) { require.NotNil(t, doc) assert.Equal(t, "a", doc.Services[0].Groups[0].Name.Source) } + +// TestDecodeOptions_TextReachesEveryField is the other half of #69's answer: a +// caller holding only text — the CLI, which imports no compiler — can set every +// option a Go caller can, including the overlay, whose value is a file the +// caller's own reader loads. +func TestDecodeOptions_TextReachesEveryField(t *testing.T) { + t.Parallel() + const overlayDoc = "overlay: 1.0.0\ninfo: {title: O, version: \"1\"}\nactions: []\n" + read := func(name string) ([]byte, error) { + if name != "patch.yaml" { + return nil, fmt.Errorf("no such file %q", name) + } + return []byte(overlayDoc), nil + } + + got, err := New().DecodeOptions(compilers.OptionSet{ + Settings: map[string]string{ + "grouping": "path-prefix", + "allow-external-refs": "true", + "overlay": "patch.yaml", + "overlay-lax": "true", + }, + ReadFile: read, + }) + require.NoError(t, err) + + opts, ok := got.(Options) + require.True(t, ok, "the decoded value must be this compiler's own options type") + assert.Equal(t, GroupByPathPrefix, opts.Grouping) + assert.True(t, opts.AllowExternalRefs) + require.NotNil(t, opts.Overlay) + assert.Equal(t, "patch.yaml", opts.Overlay.Path) + assert.Equal(t, overlayDoc, string(opts.Overlay.Data)) + assert.True(t, opts.Overlay.Lax) +} + +func TestDecodeOptions_EmptySetIsDefaults(t *testing.T) { + t.Parallel() + got, err := New().DecodeOptions(compilers.OptionSet{}) + require.NoError(t, err) + assert.Equal(t, Options{}, got) +} + +func TestDecodeOptions_Refusals(t *testing.T) { + t.Parallel() + readsNothing := func(string) ([]byte, error) { return nil, errors.New("no such file") } + tests := []struct { + name string + settings map[string]string + readFile func(string) ([]byte, error) + wantErr string + }{ + {"unknown name", map[string]string{"gruoping": "tags"}, nil, + `unknown option "gruoping" (known: allow-external-refs, grouping, overlay, overlay-lax)`}, + {"unknown grouping", map[string]string{"grouping": "alphabetical"}, nil, + `want "tags" or "path-prefix", got "alphabetical"`}, + {"non-boolean", map[string]string{"allow-external-refs": "yes please"}, nil, + `want a boolean, got "yes please"`}, + {"non-boolean laxness", map[string]string{"overlay": "p.yaml", "overlay-lax": "sort of"}, + readsNothing, `want a boolean, got "sort of"`}, + {"laxness with no overlay", map[string]string{"overlay-lax": "true"}, nil, + `"overlay-lax" applies only with "overlay"`}, + {"no reader for a file", map[string]string{"overlay": "p.yaml"}, nil, + "names a file and the caller supplied no reader"}, + {"unreadable file", map[string]string{"overlay": "p.yaml"}, readsNothing, + "no such file"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + got, err := New().DecodeOptions(compilers.OptionSet{ + Settings: tc.settings, + ReadFile: tc.readFile, + }) + require.Error(t, err, "a setting that cannot be honoured must not be ignored") + assert.Contains(t, err.Error(), tc.wantErr) + assert.Nil(t, got) + }) + } +} + +// TestDecodeOptions_OverlayLaxnessIsOrderIndependent pins that the two settings +// describing one overlay are assembled after both have been read: were they +// applied as they arrived, the laxness would depend on which name sorted first. +func TestDecodeOptions_OverlayLaxnessIsOrderIndependent(t *testing.T) { + t.Parallel() + read := func(string) ([]byte, error) { return []byte("overlay: 1.0.0\n"), nil } + + strict, err := New().DecodeOptions(compilers.OptionSet{ + Settings: map[string]string{"overlay": "p.yaml", "overlay-lax": "false"}, + ReadFile: read, + }) + require.NoError(t, err) + lax, err := New().DecodeOptions(compilers.OptionSet{ + Settings: map[string]string{"overlay-lax": "true", "overlay": "p.yaml"}, + ReadFile: read, + }) + require.NoError(t, err) + + assert.False(t, strict.(Options).Overlay.Lax) + assert.True(t, lax.(Options).Overlay.Lax) +} + +// TestDecodeOptions_FeedsCompile closes the loop the CLI walks: what +// DecodeOptions returns is what Compile accepts, so a setting is not merely +// parsed but read by the lowering. +func TestDecodeOptions_FeedsCompile(t *testing.T) { + t.Parallel() + spec := "openapi: 3.1.0\ninfo: {title: T, version: \"1\"}\npaths:\n /a/b:\n get: {operationId: ab, tags: [zoo], responses: {\"200\": {description: ok}}}\n" + formatOpts, err := New().DecodeOptions(compilers.OptionSet{ + Settings: map[string]string{"grouping": "path-prefix"}, + }) + require.NoError(t, err) + + doc, _, err := New().Compile(context.Background(), []compilers.Source{openapitest.SourceOf(spec)}, + compilers.Options{FormatOptions: formatOpts}) + require.NoError(t, err) + require.NotNil(t, doc) + assert.Equal(t, "a", doc.Services[0].Groups[0].Name.Source, + "the decoded option must reach the lowering, not merely parse") +} diff --git a/docs/architecture.md b/docs/architecture.md index 2fe2ac3..0331966 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -97,9 +97,12 @@ framework that writes the type registry, derives a canonical name, or builds an Promoting something into the framework later is additive, while demoting it breaks every compiler, so borderline machinery starts outside and moves in on evidence from more than one format. -Compilers are registered in a registry keyed by detected format; the engine sniffs the source -format and dispatches. Milestone 1 ships the OpenAPI 3.x compiler only; the compiler registry, -provenance model, and IR are built for all eight from day one. +Compilers are registered in a registry keyed by the formats they report, and detection belongs to +them too: the registry asks each compiler in registration order whether it recognizes a source, and +the engine dispatches to the one that does. A compiler also decodes its own textual options, so +registering one is the whole of adding a format — no layer above names any of them. Milestone 1 +ships the OpenAPI 3.x compiler only; the compiler registry, provenance model, and IR are built for +all eight from day one. ### 2.2 IR passes (IR → IR) @@ -231,7 +234,7 @@ morphic/ │ ├── typespec/ smithy/ graphql/ asyncapi/ protobuf/ otp/ (future) ├── pass/ # Layer 1 — IR → IR passes (validate, dedup, filter, slice, overlay). ├── emitters/ # Layer 2 — emitter contract, plan layer, registry (future). -├── engine/ # Layer 3 — orchestration: sniff format, run compiler, passes, emitters. +├── engine/ # Layer 3 — orchestration: detect format, run compiler, passes, emitters. ├── internal/archtest/ # Layering, grammar, recursion and method-cap rules (tooling). ├── internal/harness/ # Bug-catching oracle sweep over a spec corpus (tooling). ├── internal/testspec/ # Spec fixtures shared by the tooling (tooling). diff --git a/engine/diag.go b/engine/diag.go index 4180ffa..0e40a4b 100644 --- a/engine/diag.go +++ b/engine/diag.go @@ -15,18 +15,18 @@ import ( // and a rejection is a finding about the spec whichever stage makes it. A Go // error out of Run means something other than the spec went wrong: the file // could not be read, or a compiler broke its own contract. +// Detection itself reports under no engine code. The engine parses nothing, so +// the account of why a source could not be read belongs to the compiler that +// recognized it — openapi/undecodable-source, for one that declares an OpenAPI +// key and will not parse. An engine code here would have to describe every +// format at once, and would be wrong for the first one that is not YAML. const ( - // codeUndecodableSource: the bytes parse as neither YAML nor JSON, so nothing - // can be read out of them, a format key included. - codeUndecodableSource = "engine/undecodable-source" - // codeUnrecognizedFormat: the source decoded but declares no key any compiler - // in this tree announces itself by. + // codeUnrecognizedFormat: no compiler claimed the source, and none of them + // had anything to say about why. codeUnrecognizedFormat = "engine/unrecognized-format" - // codeUnsupportedFormat: the source declares a format Morphic recognizes and - // cannot lower yet — Swagger 2.0 today. - codeUnsupportedFormat = "engine/unsupported-format" - // codeNoCompilerForFormat: the format was read, but no registered compiler - // claims it. An OpenAPI version outside the supported range lands here. + // codeNoCompilerForFormat: a compiler read the source and named a format, but + // none is registered for it. An OpenAPI version outside the supported range + // lands here, as does Swagger 2.0, which is recognized and not yet lowered. codeNoCompilerForFormat = "engine/no-compiler-for-format" ) diff --git a/engine/doc.go b/engine/doc.go index 835ea20..6215f09 100644 --- a/engine/doc.go +++ b/engine/doc.go @@ -1,4 +1,6 @@ -// Package engine orchestrates the Morphic pipeline: it sniffs the source format, -// dispatches to the registered compiler, and runs IR passes. It is the only -// package that composes compilers and passes together. +// Package engine orchestrates the Morphic pipeline: it asks the registered +// compilers which of them recognizes the source, dispatches to that one, and +// runs IR passes. It is the only package that composes compilers and passes +// together, and it holds no knowledge of any source format — what a spec looks +// like and what its options are called are the compiler's to answer. package engine diff --git a/engine/engine.go b/engine/engine.go index 6b52266..c2b7a81 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -2,6 +2,7 @@ package engine import ( "context" + "errors" "fmt" "os" @@ -11,13 +12,26 @@ import ( "github.com/dexpace/morphic/pass" ) -// RunOptions configures a single pipeline run. FormatOptions is forwarded -// verbatim to the compiler as compilers.Options.FormatOptions. +// RunOptions configures a single pipeline run. +// +// Compiler options arrive by one of two channels. FormatOptions is the +// programmatic one: a value of the compiler's own options type, forwarded +// verbatim as compilers.Options.FormatOptions by a caller that imports the +// compiler. CompilerOptions is the textual one, for a caller — the CLI — that +// does not: the detected compiler decodes the settings into its own options +// type itself. type RunOptions struct { - FormatOptions any `json:"formatOptions,omitempty"` - SkipValidate bool `json:"skipValidate,omitempty"` + FormatOptions any `json:"formatOptions,omitempty"` + CompilerOptions map[string]string `json:"compilerOptions,omitempty"` + SkipValidate bool `json:"skipValidate,omitempty"` } +// errOptionChannels reports both option channels set at once. Which one wins +// would be a precedence rule no caller can see the effect of, and a run +// configured two ways is a mistake in the caller rather than a case to resolve. +var errOptionChannels = errors.New( + "engine: set FormatOptions or CompilerOptions, not both") + // Result is the outcome of a pipeline run. A nil Document alongside diagnostics // is a legal outcome and the shape every refusal takes — a source no compiler // claims, or one a compiler declined to lower; the caller decides what is fatal. @@ -30,8 +44,8 @@ type Result struct { Format compilers.SourceFormat `json:"format"` } -// Engine orchestrates the sniff → compiler → passes pipeline over a registry of -// compilers. +// Engine orchestrates the detect → compiler → passes pipeline over a registry +// of compilers. type Engine struct { registry *compilers.Registry } @@ -48,8 +62,9 @@ func New() (*Engine, error) { // compilers claiming the same format) alike surface as a Go error rather than a // panic, and the error names the argument position so a caller passing several // compilers can tell which one was rejected. Calling NewWith with no compilers -// is legal: the resulting engine's Run always fails at the lookup step, which is -// the seam TestEngine_RunLookupMiss relies on to reach that branch. +// is legal: the resulting engine's Run always fails at detection, with nobody +// to ask and so nothing to report but that the format went unrecognized, which +// is the seam TestEngine_RunNothingRegistered relies on to reach that branch. func NewWith(fronts ...compilers.Compiler) (*Engine, error) { reg := compilers.NewRegistry() for i, front := range fronts { @@ -60,9 +75,9 @@ func NewWith(fronts ...compilers.Compiler) (*Engine, error) { return &Engine{registry: reg}, nil } -// Run executes the pipeline for the spec at specPath: read the file, sniff its -// format, dispatch to the matching compiler, and — unless disabled — append the -// validate pass's diagnostics. +// Run executes the pipeline for the spec at specPath: read the file, ask the +// registered compilers which of them recognizes it, dispatch to that one, and — +// unless disabled — append the validate pass's diagnostics. // // The Go error return is reserved for I/O and programmer errors: the file could // not be read, or a compiler failed in a way its own contract calls an error. @@ -75,19 +90,18 @@ func (e *Engine) Run(ctx context.Context, specPath string, opts RunOptions) (*Re if err != nil { return nil, fmt.Errorf("engine: read spec %q: %w", specPath, err) } - format, problem, ok := Sniff(data) + source := compilers.Source{Path: specPath, Data: data} + + front, format, declined, ok := e.registry.Detect(source) if !ok { - return &Result{Diagnostics: []ir.Diagnostic{problem}}, nil + return &Result{Format: format, Diagnostics: undetected(format, declined)}, nil } - front, registered := e.registry.Lookup(format) - if !registered { - return &Result{Format: format, Diagnostics: []ir.Diagnostic{ - specProblem(codeNoCompilerForFormat, "no compiler registered for format %s", format), - }}, nil + formatOpts, err := formatOptions(front, opts) + if err != nil { + return nil, fmt.Errorf("engine: options for %q: %w", specPath, err) } - doc, diags, err := front.Compile(ctx, - []compilers.Source{{Path: specPath, Data: data}}, - compilers.Options{FormatOptions: opts.FormatOptions}) + doc, diags, err := front.Compile(ctx, []compilers.Source{source}, + compilers.Options{FormatOptions: formatOpts}) if err != nil { return nil, fmt.Errorf("engine: parse %q: %w", specPath, err) } @@ -104,3 +118,47 @@ func (e *Engine) Run(ctx context.Context, specPath string, opts RunOptions) (*Re doc.Diagnostics = mergeDiagnostics(doc.Diagnostics, diags) return &Result{Document: doc, Diagnostics: doc.Diagnostics, Format: format}, nil } + +// undetected reports a source no registered compiler will take. None of the +// three cases is an I/O failure or a programmer error, so none may leave Run as +// a Go error: a caller that maps Go errors to "you invoked me wrong" — which the +// CLI does — would report a spec it read as a misuse of itself. +// +// A named format means a compiler read the source and named one this build does +// not carry: a Swagger 2.0 document, say, whose shape morphic understands and +// does not yet compile. Otherwise nothing recognized the bytes, and the only +// account of why is whatever the compilers that declined chose to give — +// preferred over the engine's own, because the engine parses nothing and can +// say no more than that nobody claimed it. +func undetected(format compilers.SourceFormat, declined []ir.Diagnostic) []ir.Diagnostic { + if format.Name != "" { + return []ir.Diagnostic{specProblem(codeNoCompilerForFormat, + "no compiler registered for format %s", format)} + } + if len(declined) > 0 { + return declined + } + return []ir.Diagnostic{specProblem(codeUnrecognizedFormat, "unrecognized spec format")} +} + +// formatOptions resolves the compiler options for one run, decoding the textual +// channel through the compiler that will read them. os.ReadFile is what makes a +// path-valued setting work: the engine already reads the spec, so loading a file +// a setting names keeps the I/O on this side of the contract and the compiler +// pure. +func formatOptions(front compilers.Compiler, opts RunOptions) (any, error) { + if len(opts.CompilerOptions) == 0 { + return opts.FormatOptions, nil + } + if opts.FormatOptions != nil { + return nil, errOptionChannels + } + decoded, err := front.DecodeOptions(compilers.OptionSet{ + Settings: opts.CompilerOptions, + ReadFile: os.ReadFile, + }) + if err != nil { + return nil, fmt.Errorf("decode: %w", err) + } + return decoded, nil +} diff --git a/engine/engine_test.go b/engine/engine_test.go index a707c44..c09495b 100644 --- a/engine/engine_test.go +++ b/engine/engine_test.go @@ -2,10 +2,12 @@ package engine_test import ( "context" + "errors" "fmt" "os" "path/filepath" "slices" + "strings" "testing" "github.com/google/go-cmp/cmp" @@ -20,11 +22,53 @@ import ( func writeSpec(t *testing.T, contents string) string { t.Helper() - path := filepath.Join(t.TempDir(), "spec.yaml") + return writeNamed(t, "spec.yaml", contents) +} + +func writeNamed(t *testing.T, name, contents string) string { + t.Helper() + path := filepath.Join(t.TempDir(), name) require.NoError(t, os.WriteFile(path, []byte(contents), 0o644)) return path } +// TestEngine_RunUnrecognizedFormat drives the input three of the five planned +// compilers take. None of it is YAML, and none of it is any concern of the YAML +// parser: what a user needs back is that no registered compiler claims these +// bytes, named in the vocabulary of spec formats. +// +// Running the parser first made the answer an accident of whether the bytes +// happened to parse — a one-line GraphQL document parses and a two-line one does +// not — and when they did not, the message quoted a decoder's complaint about an +// engine-internal type. +func TestEngine_RunUnrecognizedFormat(t *testing.T) { + t.Parallel() + cases := []struct{ name, file, src string }{ + {"protobuf", "svc.proto", "syntax = \"proto3\";\npackage demo;\nservice S { rpc Get (Q) returns (A); }\n"}, + {"typespec", "main.tsp", "import \"@typespec/http\";\nnamespace Demo;\nmodel Pet { name: string; }\n"}, + {"graphql", "schema.graphql", "type Query {\n pet(id: ID!): Pet\n}\n"}, + {"graphql one line", "one.graphql", "type Query { a: String }\n"}, + {"yaml that is no spec", "junk.yaml", "hello: world\n"}, + } + eng, err := engine.New() + require.NoError(t, err) + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + res, err := eng.Run(t.Context(), writeNamed(t, tc.file, tc.src), engine.RunOptions{}) + + require.NoError(t, err, "another format's bytes are not a Go error") + require.NotNil(t, res) + assert.Nil(t, res.Document) + require.Len(t, res.Diagnostics, 1) + assert.Equal(t, "engine/unrecognized-format", res.Diagnostics[0].Code) + assert.NotContains(t, res.Diagnostics[0].Message, "yaml:", + "a parser's complaint is not an answer: none of these declares an OpenAPI key") + }) + } +} + func TestEngine_RunEndToEnd(t *testing.T) { t.Parallel() eng, err := engine.New() @@ -100,19 +144,27 @@ func TestEngine_RunMissingFile(t *testing.T) { require.Error(t, err) } -// TestEngine_RunSniffProblemsAreDiagnostics covers every way a source can defeat -// the sniff step. None of them is an I/O failure or a programmer error, so none -// may leave Run as a Go error: a caller that maps Go errors to "you invoked me -// wrong" — which the CLI does — would report a spec it read and understood well -// enough to name the problem in as a misuse of itself. -func TestEngine_RunSniffProblemsAreDiagnostics(t *testing.T) { +// TestEngine_RunDetectionProblemsAreDiagnostics covers every way a source can +// defeat detection. None of them is an I/O failure or a programmer error, so +// none may leave Run as a Go error: a caller that maps Go errors to "you invoked +// me wrong" — which the CLI does — would report a spec it read and understood +// well enough to name the problem in as a misuse of itself. +// +// The three rows are three different answers. A Swagger document is recognized +// and unserved, so the format it declared survives into the Result. Bytes that +// declare no key at all are nobody's, and no compiler has anything to say. Bytes +// that declare an OpenAPI key and will not parse are the OpenAPI compiler's own, +// and it — not the engine, which parses nothing — reports the parse error. +func TestEngine_RunDetectionProblemsAreDiagnostics(t *testing.T) { t.Parallel() tests := []struct { name, spec, code string + wantFormat compilers.SourceFormat }{ - {"swagger 2.0", "swagger: \"2.0\"\n", "engine/unsupported-format"}, - {"unrecognized", "hello: world\n", "engine/unrecognized-format"}, - {"undecodable", "openapi: [unterminated\n", "engine/undecodable-source"}, + {"recognized but unserved", "swagger: \"2.0\"\ninfo: {}\n", + "engine/no-compiler-for-format", compilers.SourceFormat{Name: "swagger", Version: "2.0"}}, + {"unrecognized", "hello: world\n", "engine/unrecognized-format", compilers.SourceFormat{}}, + {"undecodable", "openapi: [unterminated\n", "openapi/undecodable-source", compilers.SourceFormat{}}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -130,17 +182,17 @@ func TestEngine_RunSniffProblemsAreDiagnostics(t *testing.T) { assert.Equal(t, ir.SeverityError, res.Diagnostics[0].Severity) assert.Equal(t, ir.NoSource, res.Diagnostics[0].Provenance.Source, "the engine read a file it never lowered, so it can index no source table") - assert.Equal(t, compilers.SourceFormat{}, res.Format, "no format was determined") + assert.Equal(t, tt.wantFormat, res.Format) }) } } -func TestEngine_RunLookupMiss(t *testing.T) { +func TestEngine_RunNothingRegistered(t *testing.T) { t.Parallel() // NewWith() with zero compilers is load-bearing here: it is the only way to - // reach an engine with no compiler registered for the openapi 3.1 the spec - // sniffs to. Don't add a len(fronts) == 0 precondition to NewWith — doing so - // would make this branch unreachable. + // reach an engine that has nothing to ask about a source every registered + // compiler would otherwise claim. Don't add a len(fronts) == 0 precondition + // to NewWith — doing so would make this branch unreachable. eng, err := engine.NewWith() require.NoError(t, err) @@ -150,20 +202,49 @@ func TestEngine_RunLookupMiss(t *testing.T) { require.NotNil(t, res) assert.Nil(t, res.Document) require.Len(t, res.Diagnostics, 1) - assert.Equal(t, "engine/no-compiler-for-format", res.Diagnostics[0].Code) - assert.Contains(t, res.Diagnostics[0].Message, "openapi@3.1") - assert.Equal(t, compilers.SourceFormat{Name: "openapi", Version: "3.1"}, res.Format, - "the format that found no compiler is still the one the source declared") + // Unrecognized, not no-compiler-for-format: with nothing registered there is + // nobody to read the source, so the engine cannot name the format the way it + // could when it sniffed the bytes itself. It parses nothing now, and says so. + assert.Equal(t, "engine/unrecognized-format", res.Diagnostics[0].Code) + assert.Equal(t, compilers.SourceFormat{}, res.Format, + "no compiler was there to name a format") } -// collidingCompiler claims a single fixed format. Two of them registered -// together make the second Register call fail, driving NewWith's error path. -type collidingCompiler struct{} +// TestEngine_RunEmptySource pins that an empty file is nobody's spec, without a +// compiler having to be asked about zero bytes. +func TestEngine_RunEmptySource(t *testing.T) { + t.Parallel() + eng, err := engine.New() + require.NoError(t, err) + res, err := eng.Run(t.Context(), writeSpec(t, ""), engine.RunOptions{}) -func (collidingCompiler) Formats() []compilers.SourceFormat { + require.NoError(t, err, "an empty file is a spec problem, not a Go error") + require.NotNil(t, res) + assert.Nil(t, res.Document) + require.Len(t, res.Diagnostics, 1) + assert.Equal(t, "engine/unrecognized-format", res.Diagnostics[0].Code) +} + +// stubFront answers the two contract questions every stub in this file answers +// the same way: it claims openapi 3.1, recognizes anything as openapi 3.1, and +// takes no options. Embedding it leaves each stub holding only the Compile +// behaviour it exists to model. +type stubFront struct{} + +func (stubFront) Formats() []compilers.SourceFormat { return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} } +func (stubFront) Detect(compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + return compilers.SourceFormat{Name: "openapi", Version: "3.1"}, nil, true +} + +func (stubFront) DecodeOptions(compilers.OptionSet) (any, error) { return nil, nil } + +// collidingCompiler claims a single fixed format. Two of them registered +// together make the second Register call fail, driving NewWith's error path. +type collidingCompiler struct{ stubFront } + func (collidingCompiler) Compile(context.Context, []compilers.Source, compilers.Options) (*ir.Document, []ir.Diagnostic, error) { return nil, nil, nil } @@ -191,11 +272,7 @@ func TestNewWith_NilCompiler(t *testing.T) { // errCompiler claims openapi 3.1 and always fails Compile, driving Run's // parse-error branch. -type errCompiler struct{} - -func (errCompiler) Formats() []compilers.SourceFormat { - return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} -} +type errCompiler struct{ stubFront } func (errCompiler) Compile(context.Context, []compilers.Source, compilers.Options) (*ir.Document, []ir.Diagnostic, error) { return nil, nil, assert.AnError @@ -217,11 +294,7 @@ func TestEngine_RunParseError(t *testing.T) { // a nil Document short-circuits the validate step entirely, so this stub never // reaches the code that folds the two together. splitDiagCompiler is what // covers that. -type nilDocCompiler struct{} - -func (nilDocCompiler) Formats() []compilers.SourceFormat { - return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} -} +type nilDocCompiler struct{ stubFront } func (nilDocCompiler) Compile(context.Context, []compilers.Source, compilers.Options) (*ir.Document, []ir.Diagnostic, error) { return nil, []ir.Diagnostic{{Code: "x/none"}}, nil @@ -247,10 +320,9 @@ func TestEngine_RunNilDocument(t *testing.T) { // reports through and leaves storing the same values on the document optional. // The one compiler in the tree happens to do both, which is why nothing else // here notices when the engine keeps only one of the two lists. -type splitDiagCompiler struct{ stored, returned []ir.Diagnostic } - -func (splitDiagCompiler) Formats() []compilers.SourceFormat { - return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} +type splitDiagCompiler struct { + stubFront + stored, returned []ir.Diagnostic } func (c splitDiagCompiler) Compile(context.Context, []compilers.Source, compilers.Options) (*ir.Document, []ir.Diagnostic, error) { @@ -314,11 +386,7 @@ func TestEngine_RunKeepsDiagnosticsFromEitherChannel(t *testing.T) { // dangling type reference, so the validate pass — if it runs — reports // ir/dangling-type-ref. It claims the openapi 3.1 format so a tiny 3.1 spec // sniffs to it. -type danglingCompiler struct{} - -func (danglingCompiler) Formats() []compilers.SourceFormat { - return []compilers.SourceFormat{{Name: "openapi", Version: "3.1"}} -} +type danglingCompiler struct{ stubFront } func (danglingCompiler) Compile(_ context.Context, _ []compilers.Source, _ compilers.Options) (*ir.Document, []ir.Diagnostic, error) { doc := &ir.Document{ @@ -365,3 +433,124 @@ func TestEngine_ValidateRuns(t *testing.T) { assert.False(t, hasDiagCode(withoutPass.Diagnostics, "ir/dangling-type-ref"), "skipping validation suppresses the diagnostic") } + +// smithyCompiler is a compiler for a format the engine has never heard of. It +// recognizes its own input and names its own options, which is the whole of what +// registering a format takes. +type smithyCompiler struct{ options compilers.OptionSet } + +func (*smithyCompiler) Formats() []compilers.SourceFormat { + return []compilers.SourceFormat{{Name: "smithy", Version: "2.0"}} +} + +func (*smithyCompiler) Detect(src compilers.Source) (compilers.SourceFormat, []ir.Diagnostic, bool) { + if !strings.HasPrefix(string(src.Data), "$version:") { + return compilers.SourceFormat{}, nil, false + } + return compilers.SourceFormat{Name: "smithy", Version: "2.0"}, nil, true +} + +func (s *smithyCompiler) DecodeOptions(set compilers.OptionSet) (any, error) { + if _, ok := set.Settings["boom"]; ok { + return nil, errors.New("boom is not an option") + } + s.options = set + return set.Settings["shape"], nil +} + +func (*smithyCompiler) Compile(_ context.Context, _ []compilers.Source, opts compilers.Options) (*ir.Document, []ir.Diagnostic, error) { + name, _ := opts.FormatOptions.(string) + return &ir.Document{Name: name, Types: ir.TypeRegistry{}}, nil, nil +} + +// TestEngine_RunNewFormatNeedsNoEngineEdit is the acceptance criterion for a +// compiler-owned seam: a format the engine names nowhere is reached by +// registering its compiler and nothing else. Detection, the option vocabulary +// and the version grammar all belong to the compiler, so this file — and every +// other file under engine/ — mentions neither smithy nor its options. +func TestEngine_RunNewFormatNeedsNoEngineEdit(t *testing.T) { + t.Parallel() + eng, err := engine.NewWith(&smithyCompiler{}) + require.NoError(t, err) + + res, err := eng.Run(t.Context(), writeNamed(t, "model.smithy", "$version: \"2\"\nnamespace demo\n"), + engine.RunOptions{CompilerOptions: map[string]string{"shape": "Widget"}}) + require.NoError(t, err) + require.NotNil(t, res.Document) + assert.Equal(t, compilers.SourceFormat{Name: "smithy", Version: "2.0"}, res.Format) + assert.Equal(t, "Widget", res.Document.Name, "the setting reached the compiler that decoded it") +} + +// TestEngine_RunCompilerOptionsAreLoadedByTheEngine pins the file half of the +// textual channel: a compiler does no file I/O, so a setting that names one is +// read through the reader the engine supplies. +func TestEngine_RunCompilerOptionsAreLoadedByTheEngine(t *testing.T) { + t.Parallel() + front := &smithyCompiler{} + eng, err := engine.NewWith(front) + require.NoError(t, err) + spec := writeNamed(t, "model.smithy", "$version: \"2\"\n") + + _, err = eng.Run(t.Context(), spec, engine.RunOptions{ + CompilerOptions: map[string]string{"shape": "Widget"}, + }) + require.NoError(t, err) + require.NotNil(t, front.options.ReadFile, "a compiler must be handed a reader it can use") + + got, err := front.options.ReadFile(spec) + require.NoError(t, err) + assert.Contains(t, string(got), "$version") +} + +func TestEngine_RunOptionRefusals(t *testing.T) { + t.Parallel() + tests := []struct { + name string + opts engine.RunOptions + wantErr string + }{ + {"both channels", engine.RunOptions{ + FormatOptions: "programmatic", + CompilerOptions: map[string]string{"shape": "Widget"}, + }, "set FormatOptions or CompilerOptions, not both"}, + {"undecodable setting", engine.RunOptions{ + CompilerOptions: map[string]string{"boom": "yes"}, + }, "boom is not an option"}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + eng, err := engine.NewWith(&smithyCompiler{}) + require.NoError(t, err) + _, err = eng.Run(t.Context(), writeNamed(t, "model.smithy", "$version: \"2\"\n"), tc.opts) + require.Error(t, err) + assert.Contains(t, err.Error(), "engine: options for") + assert.Contains(t, err.Error(), tc.wantErr) + }) + } +} + +// TestEngine_RunCompilerOptionsReachTheOpenAPICompiler asserts through the real +// compiler what the stubs assert through the seam: the same document compiles +// differently under a setting no engine code understands. +func TestEngine_RunCompilerOptionsReachTheOpenAPICompiler(t *testing.T) { + t.Parallel() + eng, err := engine.New() + require.NoError(t, err) + spec := writeSpec(t, `openapi: 3.1.0 +info: {title: T, version: "1"} +paths: + /a/b: + get: {operationId: ab, tags: [zoo], responses: {"200": {description: ok}}} +`) + + byTag, err := eng.Run(t.Context(), spec, engine.RunOptions{}) + require.NoError(t, err) + byPath, err := eng.Run(t.Context(), spec, engine.RunOptions{ + CompilerOptions: map[string]string{"grouping": "path-prefix"}, + }) + require.NoError(t, err) + + assert.Equal(t, "zoo", byTag.Document.Services[0].Groups[0].Name.Source) + assert.Equal(t, "a", byPath.Document.Services[0].Groups[0].Name.Source) +} diff --git a/engine/sniff.go b/engine/sniff.go deleted file mode 100644 index 78cf7d6..0000000 --- a/engine/sniff.go +++ /dev/null @@ -1,61 +0,0 @@ -package engine - -import ( - yaml "gopkg.in/yaml.v3" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/ir" -) - -// sniffProbe holds the two discriminating keys read from the source bytes. YAML -// is a JSON superset, so a single yaml decode handles both JSON and YAML specs. -type sniffProbe struct { - OpenAPI string `yaml:"openapi"` - Swagger string `yaml:"swagger"` -} - -// Sniff probe-decodes the source bytes and reports the spec format they declare. -// An `openapi: 3.X.Y` key yields the openapi compiler keyed by the major.minor -// prefix; `swagger: "2.0"` is recognized but not lowerable yet; anything else, -// undecodable bytes included, yields no format. -// -// ok reports whether a format was read, and diag says why not when it was not. -// There is no Go error return because there is nothing here for one to carry: -// every way a source can defeat Sniff is a problem with that source, and this -// pipeline reports those as diagnostics. -func Sniff(data []byte) (format compilers.SourceFormat, diag ir.Diagnostic, ok bool) { - var probe sniffProbe - if err := yaml.Unmarshal(data, &probe); err != nil { - return compilers.SourceFormat{}, - specProblem(codeUndecodableSource, "decode source: %v", err), false - } - switch { - case probe.OpenAPI != "": - return compilers.SourceFormat{Name: "openapi", Version: majorMinor(probe.OpenAPI)}, - ir.Diagnostic{}, true - case probe.Swagger != "": - return compilers.SourceFormat{}, specProblem(codeUnsupportedFormat, - "swagger 2.0 is not supported yet (planned: lift into the openapi compiler)"), false - default: - return compilers.SourceFormat{}, - specProblem(codeUnrecognizedFormat, "unrecognized spec format"), false - } -} - -// majorMinor returns the "major.minor" prefix of a dotted version string, -// e.g. "3.1.0" → "3.1". Strings with fewer than two dots — a bare major -// version, or a version already in major.minor form — are returned unchanged. -func majorMinor(version string) string { - firstDot := -1 - for i := 0; i < len(version); i++ { - if version[i] != '.' { - continue - } - if firstDot < 0 { - firstDot = i - continue - } - return version[:i] - } - return version -} diff --git a/engine/sniff_test.go b/engine/sniff_test.go deleted file mode 100644 index 5e24236..0000000 --- a/engine/sniff_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package engine_test - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/dexpace/morphic/compilers" - "github.com/dexpace/morphic/engine" - "github.com/dexpace/morphic/ir" -) - -func TestSniff_Formats(t *testing.T) { - t.Parallel() - cases := []struct { - name, src string - want compilers.SourceFormat - wantCode string - }{ - {"openapi 3.1 yaml", "openapi: 3.1.0\ninfo: {}\n", compilers.SourceFormat{Name: "openapi", Version: "3.1"}, ""}, - {"openapi 3.0 json", `{"openapi": "3.0.3"}`, compilers.SourceFormat{Name: "openapi", Version: "3.0"}, ""}, - {"openapi 3.2 yaml", "openapi: 3.2.0\ninfo: {}\n", compilers.SourceFormat{Name: "openapi", Version: "3.2"}, ""}, - // A version already in major.minor form (single dot) exercises majorMinor's - // unchanged-passthrough return. - {"openapi major.minor only", "openapi: \"3.1\"\n", compilers.SourceFormat{Name: "openapi", Version: "3.1"}, ""}, - // A bare-major version (no dot) also reaches the passthrough return. Sniff - // reports what the source declared and judges none of it; an out-of-range - // version is a spec problem the registry lookup names, not this step's. - {"openapi bare major", "openapi: \"4\"\n", compilers.SourceFormat{Name: "openapi", Version: "4"}, ""}, - {"swagger", "swagger: \"2.0\"\n", compilers.SourceFormat{}, "engine/unsupported-format"}, - {"unknown", "hello: world\n", compilers.SourceFormat{}, "engine/unrecognized-format"}, - {"undecodable yaml", "openapi: [unterminated\n", compilers.SourceFormat{}, "engine/undecodable-source"}, - } - for _, tc := range cases { - t.Run(tc.name, func(t *testing.T) { - t.Parallel() - got, diag, ok := engine.Sniff([]byte(tc.src)) - - if tc.wantCode != "" { - require.False(t, ok, "a source Sniff cannot read a format from is not ok") - assert.Equal(t, tc.wantCode, diag.Code) - assert.Equal(t, ir.SeverityError, diag.Severity) - assert.NotEmpty(t, diag.Message, "a diagnostic has to say what is wrong") - assert.Equal(t, tc.want, got, "no format is reported alongside a refusal") - return - } - require.True(t, ok, "diag: %+v", diag) - assert.Equal(t, tc.want, got) - assert.Equal(t, ir.Diagnostic{}, diag, "a format that was read leaves nothing to report") - }) - } -} diff --git a/internal/archtest/arch_test.go b/internal/archtest/arch_test.go index 0179eea..34e4242 100644 --- a/internal/archtest/arch_test.go +++ b/internal/archtest/arch_test.go @@ -199,8 +199,12 @@ var rules = map[string][]string{ "github.com/stretchr/testify/assert", "github.com/stretchr/testify/require", "gopkg.in/yaml.v3"}, "pass": {module + "/ir"}, + // The orchestration. It reaches the compiler package to compose the default + // registry and nothing of any source format: what a spec looks like and what + // its options are called are answered through the contract, so no parser is + // named here. "engine": {module + "/ir", module + "/compilers", module + "/compilers/openapi", - module + "/pass", "gopkg.in/yaml.v3"}, + module + "/pass"}, "cmd/morphic": {module + "/ir", module + "/engine"}, "cmd/morphic-harness": {module + "/internal/harness"}, "internal/testspec": {},