Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Line endings are pinned rather than left to the platform.
#
# The golden corpus is compared byte-for-byte against JSON that ir/irtest always
# encodes with "\n", so a checkout that rewrote the fixtures to CRLF would fail
# every golden and conformance test for a reason unrelated to the IR. That is the
# default on Windows (core.autocrlf=true), and CI is Linux-only, so nothing here
# would report it.
#
# One rule covers the repository because every tracked file is text: Go, YAML,
# JSON, Markdown, SVG, shell. text=auto still leaves genuinely binary content
# alone if any is ever added.
* text=auto eol=lf
7 changes: 5 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,8 +88,11 @@ in the docs.
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 — one compiler per format. Imports ir (+ own format libs); never each
other, never emitters/engine.
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. Imports ir, compilers, compilers/compile
(+ 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. (Not built yet.)
engine/ Layer 3 — orchestration; imports everything below.
Expand Down
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,8 @@ package may import only the packages one layer below it.
| Package | Layer | Imports |
|---|---|---|
| `ir/` | 0 — IR nodes, IDs, traversal, JSON round-trip | stdlib only |
| `compilers/*` | 1 — one compiler per format (`compilers/openapi`) | `ir` + own format libs |
| `compilers/compile/` | 1 — what every compiler shares: type registry, diagnostics, naming and identifier grammars | `ir` only |
| `compilers/*` | 1 — one compiler per format (`compilers/openapi`) | `ir` + `compilers` + `compilers/compile` + own format libs |
| `pass/` | 1 — IR → IR passes | `ir` only |
| `emitters/*` | 2 — IR → artifacts (future) | `ir` + emitter contract |
| `engine/` | 3 — orchestration | everything below |
Expand Down
33 changes: 23 additions & 10 deletions compilers/compile/doc.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
// Package compile holds the state every spec compiler needs and the invariants
// that state carries, so each compiler does not reimplement them.
//
// It owns two things: the type registry together with the source-coordinate map
// that keeps invariant 3 true (stable IDs; one node per source coordinate), and
// diagnostic accumulation with identity dedup. Nothing else. It imports only ir.
// It owns what every compiler must agree on, and nothing else. It imports only
// ir.
//
// The package boundary is the point. An architecture test asserts that no
// package outside this one writes to an ir.TypeRegistry directly, and that rule
// is inexpressible without an outside — which is why a package this small is
// worth its own directory.
// - The type registry with the source-coordinate map that keeps invariant 3
// true (stable IDs; one node per source coordinate), and the namespace rule
// that comes with it: a node a lowering mints takes a namespace no source
// coordinate addresses.
// - Diagnostic accumulation with identity dedup.
// - The canonical naming grammar, which invariant 4 makes a property of the IR
// rather than of a compiler.
// - The identifier grammar: the kind prefix that opens an ID and the namespace
// that follows it.
//
// The package boundary is the point. Architecture tests assert that nothing but
// this package and ir writes an ir.TypeRegistry or derives a canonical name, and
// that no compiler builds an ID out of a string — that last one is asked of the
// compilers alone, because re-typing an ID that already exists is legitimate
// above them. Rules like those are inexpressible without an outside, which is
// why a package this small is worth its own directory.
//
// What deliberately stays with the compiler: ir.Document assembly (seventeen of
// its eighteen fields carry no framework invariant), recursion depth bounds (the
// right cap for a JSON Schema walk is not the right one for an SDL walk), and
// the ID-derivation scheme (Intern takes the ID as a parameter). Promoting a
// borderline item here later is additive; demoting one is a breaking change
// across every compiler, so borderline items start outside.
// the derivation an ID's path comes from — a JSON Pointer, a GraphQL structural
// path and a protobuf fully-qualified name are different things, and the format
// that computes one is the only place that can. Promoting a borderline item here
// later is additive; demoting one is a breaking change across every compiler, so
// borderline items start outside.
package compile
89 changes: 89 additions & 0 deletions compilers/compile/ids.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package compile

import (
"strings"

"github.com/dexpace/morphic/ir"
)

// A Space is the namespace an ID's path is addressed in: the format's own name
// for a node some source coordinate denotes ("openapi"), or a space of its own
// for a node a lowering mints ("composed").
//
// It is a named type rather than a string so a call cannot transpose the space
// and the path — two same-typed string parameters being exactly the argument
// order this package cannot check for a caller.
type Space string

// PrimSpace holds the primitive leaves.
//
// It is the one space deliberately shared across formats: every compiler must
// reach t/prim/string for the same leaf, or two documents lowered from different
// formats disagree about the identity of the same type. Every other space is one
// format's, and a space that is shared by accident rather than on purpose is what
// this distinction exists to make visible.
const PrimSpace Space = "prim"

// The kind prefix that opens an ID. They are spelled once here because a
// consumer switching on a prefix — a diagnostic renderer, an IR diff — reads
// every compiler's IDs, so a compiler with its own vocabulary breaks it
// (GitHub #162).
const (
typeKind = "t"
opKind = "op"
propKind = "p"
authKind = "auth"
serviceKind = "s"
)

// TypeID returns the ID of the type at path within space.
//
// The path is the compiler's own derivation and stays there: a JSON Pointer, a
// GraphQL structural path and a protobuf fully-qualified name are different
// things, and nothing here can compute them. What the framework fixes is the
// grammar around the path — the kind prefix, the space, and the separator
// between them.
func TypeID(space Space, path string) ir.TypeID { return ir.TypeID(idFor(typeKind, space, path)) }

// OpID returns the ID of the operation at path within space.
func OpID(space Space, path string) ir.OpID { return ir.OpID(idFor(opKind, space, path)) }

// PropID returns the ID of the property at path within space.
func PropID(space Space, path string) ir.PropID { return ir.PropID(idFor(propKind, space, path)) }

// AuthID returns the ID of the security scheme at path within space.
func AuthID(space Space, path string) ir.AuthID { return ir.AuthID(idFor(authKind, space, path)) }

// ServiceID returns the ID of the service at path within space.
func ServiceID(space Space, path string) ir.ServiceID {
return ir.ServiceID(idFor(serviceKind, space, path))
}

// PrimTypeID returns the shared ID of the primitive of kind k.
func PrimTypeID(k ir.PrimKind) ir.TypeID { return TypeID(PrimSpace, string(k)) }

// idFor joins a kind prefix, a space and a path with single separators.
//
// The path's leading separator is supplied here rather than assumed, so a
// compiler whose paths carry one and a compiler whose paths do not derive the
// same ID, and neither can produce "t/openapicomponents/schemas/User". A space
// with no path is an ID in its own right — the space names one node — and gets no
// trailing separator.
func idFor(kind string, space Space, path string) string {
trimmed := strings.TrimPrefix(path, "/")
if trimmed == "" {
return kind + "/" + string(space)
}
return kind + "/" + string(space) + "/" + trimmed
}

// spaceOf returns the space id is addressed in — the segment after the kind
// prefix — or "" when there is none. Only an ID that was not built here, or one
// built with an empty Space, has none.
func spaceOf(id ir.TypeID) Space {
parts := strings.SplitN(string(id), "/", 3)
if len(parts) < 2 {
return ""
}
return Space(parts[1])
}
111 changes: 111 additions & 0 deletions compilers/compile/ids_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package compile_test

import (
"testing"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/dexpace/morphic/compilers/compile"
"github.com/dexpace/morphic/ir"
)

// TestIDGrammar_KindPrefixes pins the spelling of every ID kind, because the
// prefix is what a consumer reading IDs from more than one compiler switches on.
// The expected strings are written out rather than derived: a test that built
// them the way the code does would agree with any change to it.
func TestIDGrammar_KindPrefixes(t *testing.T) {
t.Parallel()
const space compile.Space = "openapi"
const pointer = "/components/schemas/User"

assert.Equal(t, ir.TypeID("t/openapi/components/schemas/User"), compile.TypeID(space, pointer))
assert.Equal(t, ir.OpID("op/openapi/paths/~1pets/get"), compile.OpID(space, "/paths/~1pets/get"))
assert.Equal(t, ir.PropID("p/openapi/components/schemas/User/properties/id"),
compile.PropID(space, "/components/schemas/User/properties/id"))
assert.Equal(t, ir.AuthID("auth/openapi/components/securitySchemes/apiKey"),
compile.AuthID(space, "/components/securitySchemes/apiKey"))
assert.Equal(t, ir.ServiceID("s/openapi/0"), compile.ServiceID(space, "0"))
assert.Equal(t, ir.TypeID("t/prim/string"), compile.PrimTypeID(ir.PrimString))
}

// TestIDGrammar_PathSeparatorIsSuppliedOnce pins that the framework owns the
// boundary between the space and the path. A format whose paths carry a leading
// separator (an RFC 6901 pointer) and one whose paths do not (a protobuf
// fully-qualified name) must reach the same shape, and neither may run the space
// into the path.
func TestIDGrammar_PathSeparatorIsSuppliedOnce(t *testing.T) {
t.Parallel()
const space compile.Space = "protobuf"

assert.Equal(t, compile.TypeID(space, "/example.v1.User"), compile.TypeID(space, "example.v1.User"))
assert.Equal(t, ir.TypeID("t/protobuf/example.v1.User"), compile.TypeID(space, "example.v1.User"))
assert.Equal(t, ir.TypeID("t/protobuf"), compile.TypeID(space, ""),
"a space naming one node gets no trailing separator")
}

// TestTypes_MintingIntoASourceSpaceIsRefused plants invariant 3's corollary
// broken — a minted node taking a namespace that addresses source coordinates —
// and asserts it is refused whichever order the two arrive in.
//
// Both orders are asserted because that is the failure's whole character: the
// document is well-formed either way and only the winner of the collision
// changes, so a single-order test passes on a compiler that is already wrong.
func TestTypes_MintingIntoASourceSpaceIsRefused(t *testing.T) {
t.Parallel()
tests := []struct {
name string
run func(types *compile.Types)
}{
{"source coordinate first", func(types *compile.Types) {
types.Intern("/components/schemas/User", "t/openapi/components/schemas/User", model)
types.Register("t/openapi/components/schemas/User/oneOf/0", model())
}},
{"minted node first", func(types *compile.Types) {
types.Register("t/openapi/components/schemas/User/oneOf/0", model())
types.Intern("/components/schemas/User", "t/openapi/components/schemas/User", model)
}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
types := compile.NewTypes(0)
tc.run(types)
require.Len(t, types.Violations(), 1, "the collision is reported exactly once")
assert.Contains(t, types.Violations()[0], `namespace "openapi"`)
assert.Contains(t, types.Violations()[0], "needs a namespace of its own")
})
}
}

// TestTypes_SeparateSpacesAreNotRefused is the negative control: the arrangement
// the OpenAPI compiler actually uses — coordinates in openapi and anon, minted
// variants in composed — must stay silent, or the check above would be reporting
// on every compile.
func TestTypes_SeparateSpacesAreNotRefused(t *testing.T) {
t.Parallel()
types := compile.NewTypes(0)
types.Intern("/components/schemas/User", "t/openapi/components/schemas/User", model)
types.Intern("/components/schemas/User/properties/tags", "t/anon/components/schemas/User/properties/tags", model)
types.Register("t/composed/components/schemas/User/oneOf/0", model())
types.Register("t/composed/components/schemas/User/oneOf/1", model())

assert.Empty(t, types.Violations())
assert.Equal(t, 4, types.Len())
}

// TestTypes_IDWithNoSpaceSegmentClaimsNothing covers the one shape the namespace
// check cannot judge: an ID that was not built here, or was built with an empty
// Space. It is left alone rather than refused, because an ID with no namespace
// cannot be a namespace used two ways.
func TestTypes_IDWithNoSpaceSegmentClaimsNothing(t *testing.T) {
t.Parallel()
types := compile.NewTypes(0)
types.Register("bare", model())
types.Intern("/p", "bare", model)

assert.Empty(t, types.Violations())
}

// model returns a distinct empty Model, the simplest node the registry accepts.
func model() ir.TypeDef { return &ir.Model{} }
88 changes: 88 additions & 0 deletions compilers/compile/naming.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package compile

import (
"strings"
"unicode"

"github.com/dexpace/morphic/ir"
)

// NamingFor builds the neutral Naming of a name the source declares: the
// spelling it used, plus the canonical word sequence derived from it.
//
// It is the constructor to reach for wherever a source name becomes an IR name,
// because the pairing is the invariant — a Source with no Canonical leaves an
// emitter to segment the spelling itself, which is the casing decision invariant
// 4 moves out of the compilers. A Naming that carries only a Hint is a different
// case and does not come through here: nothing declared it.
func NamingFor(source string) ir.Naming {
return ir.Naming{Source: source, Canonical: CanonicalWords(source)}
}

// CanonicalWords renders name as the neutral lower_snake word sequence
// ir.Naming.Canonical promises: it splits on every non-word rune and on
// camel-case and letter/digit boundaries, lowercases, and joins with "_". It
// holds no acronym opinion beyond boundary detection; casing policy is an
// emitter concern.
//
// The framework owns this rather than each compiler because Canonical is ABI.
// Invariant 4 makes neutral naming a property of the IR, so "example.v1" cannot
// canonicalize to example_v1 from one compiler and example.v1 from another: an
// emitter reading Canonical has no way to tell which grammar produced it. Three
// copies of this function disagreed on exactly that (GitHub #163).
//
// A name written with no word rune in it at all ("***") canonicalizes to the
// empty string. Naming.Source keeps the spelling either way, so nothing is lost
// — there is simply no word sequence to report, and inventing one from the
// punctuation would be a naming opinion the IR does not hold.
func CanonicalWords(name string) string {
var words []string
var cur []rune
flush := func() {
if len(cur) > 0 {
words = append(words, strings.ToLower(string(cur)))
cur = cur[:0]
}
}
runes := []rune(name)
for i, r := range runes {
if !isWordRune(r) {
flush()
continue
}
if len(cur) > 0 && wordBoundary(cur[len(cur)-1], r, runes, i) {
flush()
}
cur = append(cur, r)
}
flush()
return strings.Join(words, "_")
}

// isWordRune reports whether r belongs to a word rather than separating two.
// Letters and digits are the word characters, and a combining mark is part of
// the letter it follows — a decomposed "é" is one letter written as two runes,
// so reading the mark as a separator would split a word in half.
//
// Everything else separates, which is what makes the result a word sequence
// whatever the source spelled the boundary as: a dot in a namespaced component
// name or a proto package, a slash in a media type or a path template, brackets
// around a query parameter, and the _/-/space a name may already use.
func isWordRune(r rune) bool {
return unicode.IsLetter(r) || unicode.IsDigit(r) || unicode.IsMark(r)
}

// wordBoundary reports whether a new word starts at runes[i] given the previous
// accumulated rune prev.
func wordBoundary(prev, r rune, runes []rune, i int) bool {
switch {
case unicode.IsUpper(r) && (unicode.IsLower(prev) || unicode.IsDigit(prev)):
return true // lower/digit -> Upper: "userID" -> user|ID
case unicode.IsUpper(prev) && unicode.IsUpper(r) && i+1 < len(runes) && unicode.IsLower(runes[i+1]):
return true // acronym tail: "HTTPServer" -> HTTP|Server
case unicode.IsLetter(prev) && unicode.IsDigit(r), unicode.IsDigit(prev) && unicode.IsLetter(r):
return true // letter<->digit: "APIKey2" -> ...Key|2
default:
return false
}
}
Loading
Loading