Skip to content

Commit b92870d

Browse files
committed
Merge branch 'main' into perf/openapi-index-lookups
The branch forked before #328, #356 and #373, so three of the four files needed more than a textual resolution: - The test helpers it calls were consolidated while it was open. ymap, yscalar, yalias and ymerge became ynode.Map/Scalar/Alias/Merge in #373, and yamlNode became openapitest.YAMLNode in #328. The bodies are identical, so these are renames. - TestRawChildNode_IsNotTheMergeAwareView asserted that a repeated key resolves to opposite ends in the two readers. #356 made RawChildNode take the last pair, as the parser does, so they now agree. The case is rewritten to assert the agreement rather than deleted: a reader drifting back to first-wins is worth failing on. - nodeview.go kept main's DocumentPath, walkPointer and tokenless, and main's ynode.MergeTag over the local const this branch predates. The index is additive to all of it.
2 parents 3435a47 + d3a3f07 commit b92870d

257 files changed

Lines changed: 31927 additions & 3954 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

.golangci.yml

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ linters:
1010
- gocritic
1111
- misspell
1212
- nilerr
13+
- nolintlint # a suppression outliving what it suppressed is invisible without this
1314
- prealloc
1415
- revive
1516
- unconvert
@@ -22,6 +23,19 @@ linters:
2223
lines: 70
2324
statements: -1
2425
ignore-comments: true
26+
nolintlint:
27+
# A directive that suppresses nothing reads as a live constraint on the code
28+
# under it, and nothing else in the gate can tell the two apart: the tree
29+
# stays green whether the finding is real or long gone. Each of these makes
30+
# one way of writing an inert directive fail instead.
31+
#
32+
# allow-unused only reaches directives naming an ENABLED linter. One naming a
33+
# disabled or nonexistent linter is ignored by the nolint processor and
34+
# reported by nothing — which is how //nolint:forcetypeassert stood here
35+
# without ever suppressing anything. That gap is GitHub #306.
36+
allow-unused: false # it no longer suppresses anything — delete it
37+
require-specific: true # bare //nolint hides findings nobody chose to accept
38+
require-explanation: true # the rationale is what a later reader re-checks against
2539
gocognit:
2640
# Calibrated against the finished tree, whose worst function scores 21.
2741
# Raising this is how a function that should have been split stays whole,

CLAUDE.md

Lines changed: 47 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -20,13 +20,23 @@ Pipeline: **compilers** (spec → IR) → **IR passes** (IR → IR) → **emitte
2020

2121
## The documents are the spec — read them first
2222

23+
`ls docs/*.md` is the set; what follows is why each one matters, not how many there are.
24+
2325
- **`docs/ir-design.md` is normative.** Field names and struct shapes in it are the contract;
2426
receiver methods and helpers are not. When implementing the IR, match its shapes exactly.
27+
- **`docs/emitter-design.md` is normative for the emitter half**, the way `ir-design.md` is for the
28+
IR. Nothing under `emitters/` exists yet, so read it before writing the first one.
2529
- `docs/architecture.md` — pipeline stages, package layout, layering rules, milestones.
2630
- `docs/ir-spec-matrix.md` — the union of source-format capabilities the IR is designed against.
2731
- `docs/prior-art.md` — the evidence base (oagen, Kiota, TypeSpec/TCGC) and the specific mistakes
2832
each Morphic decision is designed to avoid. Read this before proposing IR changes; most
2933
"simplifications" that come to mind are failure modes already rejected here.
34+
- `docs/reference-learnings.md` — the same evidence base widened to every shipped generator it
35+
surveys (its header names them), each finding carrying a verdict on a Morphic decision and
36+
citing the repo it came from.
37+
- `docs/micro-compiler-design.md` and `docs/micro-compiler-plan.md` — the restructuring that
38+
produced today's `compilers/openapi`. Both record work that has landed; read them for why the
39+
package boundaries fall where they do, not as a proposal or a backlog.
3040

3141
## Invariants that must not be violated
3242

@@ -35,7 +45,7 @@ claim (lossless, spec-agnostic, many-target). Before changing any of them, re-re
3545
in the docs.
3646

3747
1. **The IR is the ABI.** Compilers and emitters never see each other. A compiler's only output is
38-
an IR document + diagnostics; a emitter's only input is an IR document + its own options.
48+
an IR document + diagnostics; an emitter's only input is an IR document + its own options.
3949
2. **Lossless by default, lowered late.** Compilers never flatten (no `allOf` merging, no
4050
union-to-optional-fields collapse, no primary-response selection). Composition, unions,
4151
visibility, discriminators, encodings, streaming stay in source-semantic form. Lowering to what
@@ -73,8 +83,13 @@ in the docs.
7383
## Go representation conventions the design mandates
7484

7585
- **Closed sums = sealed interfaces**: unexported marker method (`typeDef()`), one concrete struct
76-
per kind, a `Kind()` accessor for switch-dispatch, and a generated switch-completeness test over
77-
the kind enum (the `assertNever` lesson). JSON encodes sums with an adjacent `kind` tag.
86+
per kind, a `Kind()` accessor for switch-dispatch, and a switch-completeness test over the kind
87+
enum (the `assertNever` lesson). None of it is code-generated — the module has no `go:generate`
88+
at all (`grep -rn go:generate --include='*.go' .` is empty). `TestTypeDef_KindDispatchIsComplete`
89+
iterates a hand-written `allKinds`, and `TestTypeDef_HandWrittenKindListsAreComplete` holds that
90+
list and every other hand-written kind list to the kinds the `ir` sources declare, so adding a
91+
kind without updating a list reddens there instead of quietly narrowing what a test covers. JSON
92+
encodes sums with an adjacent `kind` tag.
7893
- **No `float64` anywhere in the IR.** Numeric values, defaults, and constraints use arbitrary-
7994
precision decimal strings (`BigVal`). This is a hard rule (the TypeSpec `Numeric` lesson).
8095
- **Values are a separate channel from types** (`Value`/`ValueKind`), per the TypeSpec Type-vs-Value
@@ -96,17 +111,20 @@ compilers/ Layer 1 — the Compiler contract and the format-keyed registry. Im
96111
compilers, compilers/compile, its own internal/* (+ own format libs); never each
97112
other, never emitters/engine.
98113
internal/ Layer 1 — that compiler's own packages, each with its own allowlist rather than
99-
the compiler's. openapi has thirteen; the ordering among them is real and enforced,
100-
from diag (reaches only ir) up to operation. None may reach the compiler above it.
114+
the compiler's. The ordering among them is real and enforced, from diag (reaches
115+
only ir) up to operation. None may reach the compiler above it.
101116
pass/ Layer 1 — IR → IR passes. Imports ir only.
102117
emitters/* Layer 2 — imports ir + emitter contract; never compiler. (Not built yet.)
103118
engine/ Layer 3 — orchestration; imports everything below.
104-
cmd/morphic/ Layer 4 — CLI; imports engine.
119+
cmd/morphic/ Layer 4 — CLI; imports ir + engine.
120+
cmd/morphic-harness/
121+
Layer 4 — sweeps a spec or directory through the oracles; imports internal/harness.
105122
internal/ Test/tooling infrastructure, outside the pipeline (harness, archtest, testspec).
106123
```
107124

108125
The layering is enforced by `internal/archtest`, and **its `rules` map is the source of truth**
109-
this diagram is prose, and deliberately does not name the thirteen. Read them off the tree:
126+
this diagram is prose, and deliberately neither names nor counts a compiler's internal packages.
127+
Read them off the tree:
110128

111129
```bash
112130
git ls-files '*/*.go' | xargs -n1 dirname | sort -u
@@ -132,20 +150,30 @@ These all exist already — extend them rather than building a parallel mechanis
132150
Corpus under `testdata/golden/`.
133151
- **Capability conformance corpus** (`testdata/conformance/`): one minimal spec per
134152
`ir-spec-matrix.md` row per format that can express it, asserting lossless capture. This is what
135-
keeps "lossless by default" honest.
153+
keeps "lossless by default" honest. The row↔spec mapping is machine-read, not prose: matrix rows
154+
carry stable keys, each case names the keys it witnesses, and
155+
`compilers/openapi/conformance_matrix_test.go` requires every expressible row to be witnessed or
156+
listed with a reason. What it cannot check is whether a spec that *names* a row exercises that
157+
capability — that claim is read by a reviewer, so weigh it like any other.
136158
- **Oracles**: `internal/harness` drives a spec through no-panic → no error diagnostic →
137-
`irverify` invariants → JSON round-trip → determinism. `irverify` is the structural-invariant
138-
checker (stable IDs, no dangling refs, neutral naming, routable `Unmodeled`, in-range
139-
provenance); its findings are `Violation` values — *our* bugs — deliberately a channel separate
140-
from `ir.Diagnostic`, which reports problems in the source spec.
159+
`irverify` invariants → JSON round-trip → determinism → order-invariance, stopping at the first
160+
one that fires. `harness.Check` is the list — read it there rather than trusting this sentence;
161+
`go run ./cmd/morphic-harness <file|dir>` runs them over one spec or a whole tree. `irverify` is
162+
the structural-invariant checker (stable IDs, no dangling refs, neutral naming, routable
163+
`Unmodeled`, in-range provenance); its findings are `Violation` values — *our* bugs —
164+
deliberately a channel separate from `ir.Diagnostic`, which reports problems in the source spec.
141165
- **Architecture test**: `internal/archtest`, per the layering section above.
142166

143167
Beyond those, "verify by executing" below has consequences specific enough to write down as
144168
assertion shapes:
145169

146170
- **Order-dependence needs a two-order diff.** Compile the same source twice with the declaration
147171
order swapped and `cmp.Diff` the two documents. A single-order test passes on *both* orders of a
148-
colliding lowering, which is why the pointer collisions survived the suite.
172+
colliding lowering, which is why the pointer collisions survived the suite. The order-invariance
173+
oracle above is the general form of this and already runs it across the corpus under `testdata/`,
174+
so the usual way to cover a new construct is to add a spec the sweep reaches, not to hand-roll
175+
the diff. A targeted case still earns its place when it pins one lowering, because the oracle
176+
proves order-independence only for the constructs its inputs happen to contain.
149177
- A fixture's own declaration order is part of the test. A golden that happens to declare things
150178
in the order the *correct* lowering already produced cannot see the fix at all: reverting it
151179
leaves the golden green, and only the two-order oracle reddens. If a single-order case is meant
@@ -200,9 +228,12 @@ below are the ones most likely to bite in this codebase — the full guide gover
200228
- **Docs:** GoDoc on every exported symbol starting with its name, complete sentences; package
201229
comment on every package; comments explain *why*, not what.
202230
- **Serialization:** explicit JSON struct tags on every field; `omitempty` only on optional
203-
fields; custom `MarshalJSON`/`UnmarshalJSON` for special forms (the IR's sum types and
204-
`BigVal` do this); never `float64` for money — and in this repo, never in the IR at all, per the
205-
representation conventions above.
231+
fields; custom codecs for special forms, but not symmetrically — each member of the IR's
232+
`TypeDef` sum has a `MarshalJSON` that writes its adjacent `kind` tag, and *decoding* is
233+
centralized in `(*TypeRegistry).UnmarshalJSON`, which reads that tag; no member unmarshals
234+
itself, and `BigVal` has no codec at all because it *is* a string type and already marshals as
235+
a JSON string. `grep -rnE 'func .*(Unm|M)arshalJSON' ir/` is the current list; never `float64`
236+
for money — and in this repo, never in the IR at all, per the representation conventions above.
206237
- **Logging:** `log/slog` only, injected — but note the stronger repo invariant: pipeline
207238
stages don't log at all; they return diagnostics.
208239

README.md

Lines changed: 51 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -93,40 +93,77 @@ go build -o morphic ./cmd/morphic
9393
### CLI
9494

9595
`morphic compile` lowers one OpenAPI 3.x spec into Morphic IR JSON on stdout, and writes
96-
diagnostics to stderr.
96+
diagnostics to stderr. Stdout is indented for reading; a file written with `-o` is compact, which
97+
is about half the bytes, unless `--pretty` asks for the indented form. `morphic validate` runs the
98+
same pipeline over the same spec for the diagnostics and the exit code alone, writing no IR
99+
anywhere.
97100

98101
```bash
99102
morphic compile openapi.yaml # IR JSON to stdout
100103
morphic compile openapi.yaml -o api.ir.json # ...or to a file
104+
morphic validate openapi.yaml # diagnostics and exit code only
101105
```
102106

103107
```
104108
usage:
105109
morphic <command> [flags]
106110
morphic compile <spec-file> [flags]
111+
morphic validate <spec-file> [flags]
107112
```
108113

109114
`morphic`, `morphic help`, and `morphic` with a help flag (`-h`, `--help` or `-help`) print the
110-
command list. `morphic help compile` and `morphic compile --help` print a command's flags. Help
111-
always prints to stdout and exits `0`.
115+
command list. `morphic help <command>` and `morphic <command> --help` print a command's flags.
116+
Help always prints to stdout and exits `0`.
112117

113-
The flags below are `compile`'s:
118+
| Flag | Commands | Meaning |
119+
|---|---|---|
120+
| `--fail-on error\|warning` | both | Exit non-zero when a diagnostic at or above this severity is emitted (default `error`). |
121+
| `--skip-validate` | both | Skip the referential-integrity `validate` pass. |
122+
| `-o <file>` | `compile` | Write IR JSON to `<file>` instead of stdout, compact rather than indented. |
123+
| `--pretty` | `compile` | Indent the JSON `-o` writes; stdout is indented either way. |
124+
| `--explain <json-pointer>` | `compile` | Report what compiling produced at this source coordinate instead of writing the document. |
125+
| `--opt <key>=<value>` | both | Set one option on the compiler the spec selects. Repeatable; a repeated key is refused. |
126+
127+
Diagnostics print one per line as `<severity> <code> <location>: <message>`, where `<location>` is
128+
`<path>#<pointer>` for a finding in a spec file, a bare pointer for one an IR pass made about the
129+
document, and absent for one raised before any document existed.
130+
131+
Both commands use the same exit codes: `0` clean (and for any help request); `1` the spec has
132+
problems — a diagnostic reached the `--fail-on` threshold, or it could not be lowered at all, which
133+
covers an undecodable file, an unrecognized or unsupported format, and a version no compiler claims;
134+
`2` the invocation or the filesystem was wrong — a bad flag or argument, a spec that could not be
135+
read, an output that could not be written. Nothing about the spec's own contents reaches `2`.
136+
137+
#### Compiler options
138+
139+
`--opt` names an option in the vocabulary of whichever compiler recognizes the spec — morphic
140+
itself knows none of them, and an unknown name is refused by the compiler rather than ignored.
141+
The OpenAPI compiler accepts:
142+
143+
| Option | Values | Meaning |
144+
|---|---|---|
145+
| `grouping` | `tags` (default), `path-prefix` | How operations are grouped into operation groups. |
146+
| `allow-external-refs` | `true`, `false` (default) | Let `$ref` resolution leave the source document, reading files and fetching URLs. |
147+
| `overlay` | a file path | Apply an [OpenAPI Overlay](https://spec.openapis.org/overlay/latest.html) document to the source before lowering. |
148+
| `overlay-lax` | `true`, `false` (default) | Do not refuse when an overlay action's selector matches nothing. |
114149

115-
| Flag | Meaning |
116-
|---|---|
117-
| `-o <file>` | Write IR JSON to `<file>` instead of stdout. |
118-
| `--fail-on error\|warning` | Exit non-zero when a diagnostic at or above this severity is emitted (default `error`). |
119-
| `--skip-validate` | Skip the referential-integrity `validate` pass. |
120-
| `--explain <json-pointer>` | Report what compiling produced at this source coordinate instead of writing the document. |
150+
```bash
151+
morphic compile openapi.yaml --opt grouping=path-prefix --opt overlay=patch.yaml
152+
```
121153

122-
Diagnostics print one per line as `<severity> <code> <path>#<pointer>: <message>`. Exit codes:
123-
`0` clean (and for any help request), `1` a diagnostic reached the `--fail-on` threshold (or the
124-
spec could not be lowered), `2` a usage or I/O error.
154+
`1` and `2` can both be earned by one run — a spec that reached the threshold whose `-o`
155+
destination then refused the write. The verdict on the spec wins, so `1` means what it says
156+
whatever `-o` pointed at, and `2` means the run failed for a reason outside the spec. The write
157+
error is printed on stderr either way. Note that `-o` publishes by rename, so a destination whose
158+
directory will not take a temp file — `/dev/null`, a read-only directory — cannot be written to at
159+
all.
125160

126161
### Library
127162

128163
The same pipeline is available as a package. `engine.New` builds the default registry (OpenAPI
129-
compiler + `validate` pass); `Run` sniffs the format, compiles, and runs passes.
164+
compiler + `validate` pass); `Run` asks the registered compilers which of them recognizes the
165+
source, compiles, and runs passes. A Go caller can set compiler options as a typed value through
166+
`RunOptions.FormatOptions` instead of as text through `RunOptions.CompilerOptions`.
130167

131168
```go
132169
eng, err := engine.New()

cmd/morphic/args.go

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
package main
2+
3+
import (
4+
"flag"
5+
"strings"
6+
)
7+
8+
// flagTerminator is the end-of-flags marker every POSIX utility accepts: the
9+
// arguments after it are operands however they are spelled, which is the only
10+
// way to name a file that begins with "-".
11+
const flagTerminator = "--"
12+
13+
// boolFlag is the flag package's own test for a flag set by its own presence
14+
// rather than by the argument after it. The interface is unexported there, so
15+
// it is restated rather than reached for; every flag registered by BoolVar
16+
// satisfies it.
17+
type boolFlag interface{ IsBoolFlag() bool }
18+
19+
// cutTerminator returns args with a leading flagTerminator removed, and reports
20+
// whether one was there. It is the whole of terminator handling for an argument
21+
// list that defines no flags: there is nothing to stop parsing, so the marker's
22+
// only job is to say that what follows is not a flag.
23+
func cutTerminator(args []string) ([]string, bool) {
24+
if len(args) > 0 && args[0] == flagTerminator {
25+
return args[1:], true
26+
}
27+
return args, false
28+
}
29+
30+
// splitAtTerminator splits args at the first flagTerminator standing as an
31+
// argument of its own, returning what precedes it and the operands that follow.
32+
// A "--" some flag asked for is that flag's value and not a marker, so the scan
33+
// steps over it — which is the whole reason it needs fs rather than a plain
34+
// search for the token.
35+
func splitAtTerminator(fs *flag.FlagSet, args []string) (before, operands []string) {
36+
for i := 0; i < len(args); i++ {
37+
if args[i] == flagTerminator {
38+
return args[:i], args[i+1:]
39+
}
40+
if takesNextValue(fs, args[i]) {
41+
i++
42+
}
43+
}
44+
return args, nil
45+
}
46+
47+
// takesNextValue reports whether arg is a flag fs defines that reads its value
48+
// from the following argument: spelled without an inline "=value" and not
49+
// boolean. An argument fs does not define is left alone, since Parse will
50+
// reject it and the split cannot change that.
51+
func takesNextValue(fs *flag.FlagSet, arg string) bool {
52+
name, ok := flagName(arg)
53+
if !ok {
54+
return false
55+
}
56+
f := fs.Lookup(name)
57+
if f == nil {
58+
return false
59+
}
60+
b, isBool := f.Value.(boolFlag)
61+
return !isBool || !b.IsBoolFlag()
62+
}
63+
64+
// flagName returns the flag name arg spells, and whether arg is a flag whose
65+
// value would come from the next argument at all. It mirrors the flag package's
66+
// own syntax: one or two leading dashes, a name that starts with neither "-"
67+
// nor "=", and no inline "=value".
68+
func flagName(arg string) (string, bool) {
69+
if len(arg) < 2 || arg[0] != '-' {
70+
return "", false
71+
}
72+
name := strings.TrimPrefix(arg[1:], "-")
73+
if name == "" || name[0] == '-' || name[0] == '=' {
74+
return "", false
75+
}
76+
if strings.Contains(name, "=") {
77+
return "", false
78+
}
79+
return name, true
80+
}

0 commit comments

Comments
 (0)