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. Milestone 1 (the ir package +
OpenAPI 3.x compiler) is implemented and exercised end-to-end by the morphic compile CLI; see
README.md for the current milestone table and up-to-date status — read that rather than this
section, which is not kept in sync with it.
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).
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/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.
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; a 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 inPreserved— seeir-design.md §4.7. EveryPreservedentry records why it was kept (PreserveReason) 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.
- 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 generated switch-completeness test over the kind enum (theassertNeverlesson). 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.
compilers/* Layer 1 — one compiler per format. Imports ir (+ own format libs); never each
other, never emitters/engine.
pass/ Layer 1 — IR → IR passes. Imports ir only.
emitters/* Layer 2 — imports ir + emitter contract; never compiler.
engine/ Layer 3 — orchestration; imports everything below.
cmd/morphic/ Layer 4 — CLI; imports engine.
Write the import-graph assertion test alongside the first packages — it is part of the design, not an afterthought.
- Golden IR snapshots:
spec → IR → JSONsnapshot-compared per compiler corpus. - Capability conformance corpus: one minimal spec per
ir-spec-matrix.mdrow per format that can express it, asserting lossless capture. This is what keeps "lossless by default" honest. - Round-trip property:
parse → serialize → deserialize → deep-equalfor every corpus doc. - Architecture test: the import-graph assertions above.
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; customMarshalJSON/UnmarshalJSONfor special forms (the IR's sum types andBigValdo this); neverfloat64for money — and in this repo, neverfloat64in the IR at all. - 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:
gofmt -l . # must print nothing
golangci-lint run # must pass clean
go vet ./...
go test ./...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.
- Once CI exists, keep it a small number of gating jobs (build, vet, test, the architecture
import-graph test) required on PR and on push to
main, rather than many fragmented workflows — both reference repos converge on one bundled "gate" job 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. If a test is meant to fail when something breaks, break that thing in a throwaway patch and watch it fail.
- 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.
- Verify by executing, and do not generalise the result. Every rule below was learned by
shipping its opposite in this repo. Reading code agrees with it; only running something disagrees.
- Compile a probe, don't read the source. Reviews that read the code declared it clean while it was silently dropping data. Compile the input, inspect the emitted IR and the full diagnostic list.
- One verified run is not a verified class. A mutation that reddens the suite proves something about the site it was planted at, not every site of its kind. Naming the site in the sentence is the whole fix.
- A check that runs is not a check that reaches. A verifier can be complete, well-tested, wired into CI, and still never meet the output it exists to check. Establish where it actually reaches before claiming what it covers.
- A corpus shares the blind spots of the code it covers. A golden regeneration that changes
nothing looks exactly like a broken
-update. Confirm a deliberate deletion from a source makes the test fail. - 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. The existingchore: initial ir spec draftcommit already follows this. - 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. - PRs are squash-merged, and GitHub appends the PR number (
(#NNN)) to the squashed commit automatically — don't add it yourself. - 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.