This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
Morphic is in early development, past the design stage. README.md carries the milestone table
and what is implemented — read it there. Restating it here would only give it a second place to rot,
which is the same rule as "derive counts; never maintain them" below.
Standard Go tooling works today: go build ./..., go test ./...,
go test ./ir -run TestName for a single test, go vet ./....
A spec-to-SDK compiler: any API spec (OpenAPI, Swagger 2.0, TypeSpec, Smithy, GraphQL, AsyncAPI, Protobuf, Erlang/OTP module specs) → one spec-agnostic intermediate representation (IR) → idiomatic SDKs and docs. Pipeline: compilers (spec → IR) → IR passes (IR → IR) → emitters (IR → artifacts).
ls docs/*.md is the set; what follows is why each one matters, not how many there are.
docs/ir-design.mdis normative. Field names and struct shapes in it are the contract; receiver methods and helpers are not. When implementing the IR, match its shapes exactly.docs/emitter-design.mdis normative for the emitter half, the wayir-design.mdis for the IR. Nothing underemitters/exists yet, so read it before writing the first one.docs/architecture.md— pipeline stages, package layout, layering rules, milestones.docs/ir-spec-matrix.md— the union of source-format capabilities the IR is designed against.docs/prior-art.md— the evidence base (oagen, Kiota, TypeSpec/TCGC) and the specific mistakes each Morphic decision is designed to avoid. Read this before proposing IR changes; most "simplifications" that come to mind are failure modes already rejected here.docs/reference-learnings.md— the same evidence base widened to every shipped generator it surveys (its header names them), each finding carrying a verdict on a Morphic decision and citing the repo it came from.docs/micro-compiler-design.mdanddocs/micro-compiler-plan.md— the restructuring that produced today'scompilers/openapi. Both record work that has landed; read them for why the package boundaries fall where they do, not as a proposal or a backlog.
These are load-bearing design decisions, not preferences. Breaking one defeats the project's core claim (lossless, spec-agnostic, many-target). Before changing any of them, re-read the rationale in the docs.
- The IR is the ABI. Compilers and emitters never see each other. A compiler's only output is an IR document + diagnostics; an emitter's only input is an IR document + its own options.
- Lossless by default, lowered late. Compilers never flatten (no
allOfmerging, no union-to-optional-fields collapse, no primary-response selection). Composition, unions, visibility, discriminators, encodings, streaming stay in source-semantic form. Lowering to what a target language can express happens only in emitter refiners. The one documented carve-out is validation-only JSON Schema (not/if-then-else/dependentSchemas), kept verbatim inUnmodeled— seeir-design.md §4.7. EveryUnmodeledentry records why it was kept (UnmodeledReason) and where it came from, so a consumer can take only the subset it wants. - Stable IDs; names are presentation. Every named entity has a synthetic ID derived from its
source pointer (never from a display name, never rewritten by renames). Entities live in flat,
ID-keyed registries and reference each other by ID; no node embeds another named node.
Corollary — a minted node needs a namespace of its own. Any lowering that synthesizes a node
at a pointer the source can also name must derive its ID in a separate namespace, never equal
to the pointer's own ID. Two types on one pointer makes the IR depend on which declaration
lowered first, and it does so silently: no diagnostic in either order,
pass/validateclean on both.ir-design.md §4.3records this for distributed unions; the rule is the mechanism, not that one site. - Names are neutral, never cased.
Namingstores source name + neutral canonical word sequence + wire name (+ numeric wire ID where applicable). The IR never stores camelCase / PascalCase — emitters own all identifier rendering, casing, and reserved-word escaping. - Pure, reentrant stages. Every stage is
f(input, options) → (output, diagnostics)with no package-level mutable state. No stage writes to stderr; each emits typedDiagnosticvalues (severity, stable string code, message, provenance). The engine/CLI decides what is fatal. - Heuristics are policy, not semantics. Anything inferred rather than declared (pagination
from param names, envelope detection, acronym casing) lives in injectable per-compiler/emitter
policy, is marked
Inferredin provenance, and can be disabled. - Serializable & deterministic. The whole
Documentround-trips through JSON (maps emitted in sorted-key order, slices in source order). This underpins golden snapshots, IR diffing, caching. - Optionality ≠ nullability.
Property.Required(wire presence) is orthogonal toTypeRef.Nullable(this usage admits null). Both are needed for the four distinct states. - The IR capability surface is complete from day one. Only compilers are staged over time; shipping OpenAPI first must never force an IR schema change when later compilers land.
- SDK runtime policy is not IR. Retry/timeout/telemetry/error-class taxonomy is a separate emitter input. The IR describes the API; policy describes the SDK.
- Closed sums = sealed interfaces: unexported marker method (
typeDef()), one concrete struct per kind, aKind()accessor for switch-dispatch, and a switch-completeness test over the kind enum (theassertNeverlesson). None of it is code-generated — the module has nogo:generateat all (grep -rn go:generate --include='*.go' .is empty).TestTypeDef_KindDispatchIsCompleteiterates a hand-writtenallKinds, andTestTypeDef_HandWrittenKindListsAreCompleteholds that list and every other hand-written kind list to the kinds theirsources declare, so adding a kind without updating a list reddens there instead of quietly narrowing what a test covers. JSON encodes sums with an adjacentkindtag. - No
float64anywhere in the IR. Numeric values, defaults, and constraints use arbitrary- precision decimal strings (BigVal). This is a hard rule (the TypeSpecNumericlesson). - Values are a separate channel from types (
Value/ValueKind), per the TypeSpec Type-vs-Value split. Defaults, consts, literals, enum values, examples are typed data, not type nodes. - Containers (list/map/tuple) are real type nodes with IDs, hoisted like any anonymous type — never flags on a reference.
ir/ Layer 0 — IR nodes, IDs, traversal, JSON round-trip. Imports ONLY the stdlib.
irverify/ Layer 0 — structural-invariant checks over a compiled Document. Imports ir only.
irtest/ Layer 0 — golden-file helper (`-update` rewrites). Imports ir + go-cmp.
compilers/ Layer 1 — the Compiler contract and the format-keyed registry. Imports ir only.
compile/ Layer 1 — what every compiler shares: the type registry with its coordinate map,
diagnostics, and the naming and identifier grammars. Imports ir only.
<format>/ Layer 1 — one compiler per format: its public face and nothing else — the
Compiler, its Options, and the run that calls its lowerings in order. Imports ir,
compilers, compilers/compile, its own internal/* (+ own format libs); never each
other, never emitters/engine.
internal/ Layer 1 — that compiler's own packages, each with its own allowlist rather than
the compiler's. The ordering among them is real and enforced, from diag (reaches
only ir) up to operation. None may reach the compiler above it.
pass/ Layer 1 — IR → IR passes. Imports ir only.
emitters/* Layer 2 — imports ir + emitter contract; never compiler. (Not built yet.)
engine/ Layer 3 — orchestration; imports everything below.
cmd/morphic/ Layer 4 — CLI; imports ir + engine.
cmd/morphic-harness/
Layer 4 — sweeps a spec or directory through the oracles; imports internal/harness.
internal/ Test/tooling infrastructure, outside the pipeline (harness, archtest, testspec).
The layering is enforced by internal/archtest, and its rules map is the source of truth —
this diagram is prose, and deliberately neither names nor counts a compiler's internal packages.
Read them off the tree:
git ls-files '*/*.go' | xargs -n1 dirname | sort -uThree things follow when you add a package: put it in rules (or in exempt with the reason it
is out), because TestImportGraph_EveryPackageIsRuledOrExempt fails otherwise; remember an
unkeyed subdirectory is audited under its nearest keyed ancestor's allowlist, so nesting is not a
way to widen one; and give it its own entry rather than letting it inherit a wider one, since an
inherited allowlist says nothing about where the package sits.
Two caps guard shape rather than the graph. No type under compilers/ may carry more than 20
methods (internal/archtest), and no function may exceed 70 lines or a cognitive complexity of 20
(.golangci.yml, tests excluded). The method cap is the one that matters: the god object this
structure replaced reached 159 methods without ever writing a long function, so every rule then in
force stayed green while it grew. A failure is a prompt to ask what the type has started doing.
These all exist already — extend them rather than building a parallel mechanism:
- Golden IR snapshots:
spec → IR → JSON, compared viair/irtest(-updaterewrites). Corpus undertestdata/golden/. - Capability conformance corpus (
testdata/conformance/): one minimal spec perir-spec-matrix.mdrow per format that can express it, asserting lossless capture. This is what keeps "lossless by default" honest. The row↔spec mapping is machine-read, not prose: matrix rows carry stable keys, each case names the keys it witnesses, andcompilers/openapi/conformance_matrix_test.gorequires every expressible row to be witnessed or listed with a reason. What it cannot check is whether a spec that names a row exercises that capability — that claim is read by a reviewer, so weigh it like any other. - Oracles:
internal/harnessdrives a spec through no-panic → no error diagnostic →irverifyinvariants → JSON round-trip → determinism → order-invariance, stopping at the first one that fires.harness.Checkis the list — read it there rather than trusting this sentence;go run ./cmd/morphic-harness <file|dir>runs them over one spec or a whole tree.irverifyis the structural-invariant checker (stable IDs, no dangling refs, neutral naming, routableUnmodeled, in-range provenance); its findings areViolationvalues — our bugs — deliberately a channel separate fromir.Diagnostic, which reports problems in the source spec. - Architecture test:
internal/archtest, per the layering section above.
Beyond those, "verify by executing" below has consequences specific enough to write down as assertion shapes:
- Order-dependence needs a two-order diff. Compile the same source twice with the declaration
order swapped and
cmp.Diffthe two documents. A single-order test passes on both orders of a colliding lowering, which is why the pointer collisions survived the suite. The order-invariance oracle above is the general form of this and already runs it across the corpus undertestdata/, so the usual way to cover a new construct is to add a spec the sweep reaches, not to hand-roll the diff. A targeted case still earns its place when it pins one lowering, because the oracle proves order-independence only for the constructs its inputs happen to contain.- A fixture's own declaration order is part of the test. A golden that happens to declare things in the order the correct lowering already produced cannot see the fix at all: reverting it leaves the golden green, and only the two-order oracle reddens. If a single-order case is meant to guard an order-dependent fix, declare them in the order that was wrong, and confirm the revert reddens it rather than assuming the case covers what its name says.
- A negative fixture must be reachable.
harness.Checkreturns at the first error diagnostic, beforeirverifyever runs, so a fixture that trips one never reaches the invariant checks it was written to exercise. Verify the fixture lands where you think it does, not merely that the check exists.
All Go in this repo follows the org styleguide at
dexpace/styleguide (go/ chapters 01–13). It extends
the Google Go Style Guide; where they conflict,
Google wins except for the recorded deviations (function-size cap, assertion density, bounded
recursion). Priority order: correctness > performance > developer experience. The rules
below are the ones most likely to bite in this codebase — the full guide governs everything else.
- Functions: 70-line hard cap, aim 20–40. One thing each; blank lines between logical
sections; guard clauses and early returns over nesting; no
elseafter a returningif. - Assert aggressively (TigerBeetle discipline): validate at every public boundary; aim ≥2 assertions (precondition/postcondition/invariant checks returning errors) per function; split compound checks. Never accept garbage silently.
- Bounded everything. Every loop, queue, retry, buffer, and timeout has an explicit limit. Recursion is permitted only with a provably bounded depth: an explicit counter checked against a named hard cap, recovered at the public boundary. This applies directly to schema lowering and IR traversal — deep/cyclic specs are normal inputs, not edge cases.
- Errors are values. Handle every error (
_ = erris banned); wrap with%w+ context, one level,%wat the end; branch witherrors.Is/errors.As, never string matching; sentinel errorsErr…, error types…ErrorimplementingUnwrap; error strings lowercase, unpunctuated; no in-band errors (no-1/""for "not found"); no panics escaping a package (Must*only in init/tests). Note: pipeline stages additionally report spec problems asir.Diagnosticvalues, not Go errors — Go errors are for I/O and programmer errors. - API design: accept interfaces, return concrete structs; small consumer-defined
interfaces;
anynotinterface{}; comma-ok on every type assertion; type switches carry adefault; zero values useful or obviously invalid; copy slices/maps at API boundaries; no mutable globals (this repo's "pure, reentrant stages" invariant is the same rule). - Concurrency: prefer synchronous APIs — the caller adds concurrency;
ctx context.Contextfirst parameter, never stored in a struct; every goroutine has a documented lifetime and stop mechanism;errgroupfor groups; bounded fan-out. - Naming: MixedCaps; scope-proportional length; no
Getprefix on getters; initialisms in one consistent case; package names short lowercase nouns; never shadow builtins or imports. - Testing: table-driven and flat;
TestFunc_Scenarionames;testify/requirefor preconditions,assertfor values; compare withcmp.Diff, neverreflect.DeepEqual; golden files for complex expected output;t.Helper()in helpers;t.Cleanup()overdefer; external test packages (package foo_test) preferred;goleakwhere goroutines exist. - Packages: one package per directory;
internal/aggressively for implementation detail; noutils/helpers/common;doc.gofor package docs; imports in threegcigroups (stdlib, external, local); no dot imports. - Docs: GoDoc on every exported symbol starting with its name, complete sentences; package comment on every package; comments explain why, not what.
- Serialization: explicit JSON struct tags on every field;
omitemptyonly on optional fields; custom codecs for special forms, but not symmetrically — each member of the IR'sTypeDefsum has aMarshalJSONthat writes its adjacentkindtag, and decoding is centralized in(*TypeRegistry).UnmarshalJSON, which reads that tag; no member unmarshals itself, andBigValhas no codec at all because it is a string type and already marshals as a JSON string.grep -rnE 'func .*(Unm|M)arshalJSON' ir/is the current list; neverfloat64for money — and in this repo, never in the IR at all, per the representation conventions above. - Logging:
log/slogonly, injected — but note the stronger repo invariant: pipeline stages don't log at all; they return diagnostics.
Before claiming any Go work done, run and pass the gate:
make gateThat is not a summary of CI — it is what CI runs. Every check in .github/workflows/gate.yml runs a
Makefile target bar lint, which the golangci-lint action runs at the version the Makefile
pins, so the local command and the job are the same commands in the same order. Read the Makefile
for the step list rather than restating it here; make coverage, make fuzz, make bench and the
rest are individually runnable while iterating.
Coverage is a gate at exactly 100%, not a target. scripts/check-coverage.sh counts
statements from the profile rather than reading go test's rounded percentage, so one uncovered
statement fails the build — go test ./... passing locally is not evidence the gate passes.
These match the conventions already in force across the other dexpace SDK repos
(dexpace/java-sdk, dexpace/dexpace-react) — kept consistent so contributors don't have to
context-switch between repos.
- Branch from
main:type/short-desc(e.g.feat/openapi-compiler-skeleton,fix/ir-nullable-defaulting,docs/architecture-milestone-2). - No CODEOWNERS, PR/issue templates, or CODE_OF_CONDUCT.md exist in this repo — don't assume them or invent content for files that aren't there.
- CI is one bundled
gatejob (.github/workflows/gate.yml) on PR and on push tomain. Keep it that way — add steps to it rather than new workflows; both reference repos converge on one bundled gate over granular per-check pipelines.
-
Zero nits, zero flaws — correctness before shipping. A PR is not ready because it works; it is ready when there is nothing left to find. Every known defect is fixed or explicitly justified in writing before the PR opens — never deferred silently, never left for the reviewer to catch. This applies to the small things too: a doc comment that is imprecise, a commit subject over the 72-char cap, a test that passes without asserting what its name claims. "Minor" is a severity, not a permission to ship it.
- Review the final state, not an intermediate one. If commits land after a review, that review is stale — re-review the branch as it will be merged.
- Prefer proving a claim over asserting it — see "verify by executing" below.
- A limitation that is deliberately out of scope must be stated in the code and in the PR body, in a place the next reader will actually reach — not only in a commit message or a scratch note.
- A line you were forced onto may already have a decision recorded. When a change makes an
unrelated line fail to compile or fail a new check, search the tracker for it before choosing —
gh issue list --search "<file or symbol>". A guard tightened in #185 made oneir.Namingliteral a violation; the fix picked one of two spellings without knowing #184 had already framed that exact choice, said which way it should go, and said it wanted its own change. Correcting it took a second PR. Follow the recorded reasoning or say why you are departing from it, but do not settle a recorded decision as a side effect of something else.
-
Verify by executing. Every rule below was learned by shipping its opposite here, and they are all one mistake: asking a question whose answer the thing under test already controls. Reading the code, reading the test, regenerating the golden, compiling one declaration order — none of these can disagree with you. Disagreement has to enter from outside: a planted mutation, a probe, the opposite order.
- Probe, don't read. Reviews that read the code pronounced it clean while it was silently dropping data. Compile the input; inspect the emitted IR and the full diagnostic list.
- Break what a test claims to catch. Assertions that look correct on the page find nothing. Tests here have been caught asserting nothing at all, and one "safety net" permitted the exact addition it advertised catching. Plant the defect and watch it go red.
- A regeneration that changes nothing looks exactly like a broken
-update. A corpus shares the blind spots of the code it covers, so confirm a deliberate deletion from a source spec reddens the suite. - A check that runs is not a check that reaches. A verifier can be complete, well-tested and wired into CI while never meeting the output it exists to check — an early return upstream, or an input class no committed fixture produces, is enough. Establish where it reaches before claiming what it covers; the testing section names the live instance.
- Scope a result narrowly, a defect widely. These pull opposite ways on purpose. A mutation proves something about the site it was planted at, not every site of its kind — so name the site in the sentence. But a defect is a property of its mechanism: the pointer collision was fixed in the union lowering and left standing in the inline-position hoist on the same branch, because the fix was scoped to where it was noticed. Sweep every site of the mechanism before closing it.
- Derive counts; never maintain them. Counts and universals in prose are where this repo's errors concentrate, and no test can catch them. Prefer the command that derives a number, or omit it. If a claim about code must be written down, name the revision it was true at.
-
Conventional Commits:
type(scope): subject, imperative mood, subject line only (no period, ≤72 chars). Common types:feat,fix,refactor,docs,test,build,chore,ci,perf. Scope is the touched package (ir,compilers/openapi,pass,emitters/go,engine) when it narrows things down — omit it when the change is repo-wide. -
Breaking changes mark the type with
!(feat!:,refactor!:) and explain the break in the commit body — don't bury it in the subject line alone. -
The PR title is the commit subject. PRs are squash-merged, so the title — not the branch's commit subjects — is what lands on
main. It takes the same Conventional Commits form and the same rules:type(scope): subject, imperative, no trailing period. A well-formed commit under a prose PR title still merges as a non-conforming commit, and since the squashed subject is the title, every non-conforming subject in this history arrived exactly that way. To see the current state rather than trust this sentence:git log --format='%s' origin/main | grep -E '\(#[0-9]+\)$' | grep -vE '^(feat|fix|refactor|docs|test|build|chore|ci|perf)(\(.+\))?!?: '
-
Budget the appended number inside the 72-char cap. GitHub appends
(#NNN)to the squashed subject automatically — never type it yourself, but do count it: a three-digit PR leaves the title 65 characters, not 72. Measure before opening, don't eyeball —printf '%s (#999)' "<title>" | wc -c. Most over-length subjects in this history were within the cap until the number was appended, which is the whole failure mode; to see that split rather than trust it, pipe the log above throughawk 'length($0) > 72'and then throughsed -E 's/ \(#[0-9]+\)$//'before the sameawk. -
PR description: Summary / Test plan (/ Breaking, when applicable). Keep PRs scoped to one logical change; split unrelated changes into separate PRs.
-
Write self-contained, human-framed titles/descriptions. No LLM/session artifacts, no internal audit/finding IDs, no "remediation"/"audit sweep" framing. State problem, change, and rationale on their own terms.