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
9 changes: 2 additions & 7 deletions compilers/compile/compile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,12 +10,6 @@ import (
"github.com/dexpace/morphic/ir"
)

func TestPrimTypeID_IsTheSharedScheme(t *testing.T) {
t.Parallel()
assert.Equal(t, ir.TypeID("t/prim/string"), compile.PrimTypeID(ir.PrimString),
"every compiler must reach the same ID for the same primitive")
}

// TestTypes_InternIsIdempotentAndRecordsBeforeBuilding pins the property that
// terminates recursion: the coordinate is recorded before build runs, so a
// self-reference reached during build resolves instead of re-entering.
Expand Down Expand Up @@ -86,7 +80,8 @@ func TestTypes_PrimRefInternsOnceAndStampsSource(t *testing.T) {
types := compile.NewTypes(7)

first := types.PrimRef(ir.PrimString)
assert.Equal(t, compile.PrimTypeID(ir.PrimString), first.Target)
assert.Equal(t, ir.PrimTypeID(ir.PrimString), first.Target,
"the framework interns at the shared ID rather than deriving one of its own")
assert.Equal(t, first.Target, types.PrimID(ir.PrimString), "a second reach is the same ID")
assert.Equal(t, 1, types.Len(), "and interns nothing further")

Expand Down
15 changes: 4 additions & 11 deletions compilers/compile/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,10 @@ import (
// 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 primitive leaves take no Space of their own here. Their ID is
// ir.PrimTypeID, derived in ir beside the PrimKind that is the whole of a
// primitive's identity — the one path no compiler owns, and so the one an ir
// consumer can check for itself (GitHub #73).

// The kind prefix that opens an ID. A consumer switching on a prefix — a
// diagnostic renderer, an IR diff, the structural verifier — reads every
Expand Down Expand Up @@ -63,9 +59,6 @@ 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
Expand Down
1 change: 0 additions & 1 deletion compilers/compile/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ func TestIDGrammar_KindPrefixes(t *testing.T) {
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
Expand Down
8 changes: 7 additions & 1 deletion compilers/compile/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -220,8 +220,14 @@ func (t *Types) Node(id ir.TypeID) (ir.TypeDef, bool) {
// PrimRef interns the primitive of kind k on first use and returns a reference
// to it. Primitives are leaves reached by kind rather than by position, so they
// never enter the pointer-keyed table.
//
// It writes the registry directly, claiming neither the ID nor the space: both
// claims are about a coordinate owning an ID, and a primitive has no coordinate.
// What that leaves unguarded here — another node landing in the prim space — is
// caught at the document boundary by irverify's ir/prim-space-reserved, which
// holds every producer rather than only a compile that went through this type.
func (t *Types) PrimRef(k ir.PrimKind) ir.TypeRef {
id := PrimTypeID(k)
id := ir.PrimTypeID(k)
if _, ok := t.reg[id]; !ok {
t.reg[id] = &ir.Primitive{
TypeCommon: ir.TypeCommon{ID: id, Provenance: ir.Provenance{Source: t.src}},
Expand Down
10 changes: 10 additions & 0 deletions docs/ir-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,16 @@ nothing outside the format can compute one. A node a lowering *mints* rather tha
namespace of its own, so no pointer a reference can spell ever reaches it — the general form of the
rule §4.3 states for distributed unions.

Primitives are the one exception, and only because the rule's premise does not hold for them: a
primitive occupies no source position, so there is no path for a format to own. Its identity is
its `PrimKind`, and its ID is `t/prim/<kind>` — derived by `ir.PrimTypeID` rather than by any
compiler, `ir` being the only place that can compute it. Two documents lowered from different
formats must reach that same node for the same kind, or they disagree about the identity of the
same type. The `prim` namespace is reserved for exactly those nodes: anything else addressed there
either collides with the primitive of that kind or squats the name of the next one. `irverify`
holds both halves — `ir/prim-id-not-derived` and `ir/prim-space-reserved` — for every document,
whatever produced it.

Every named entity has an ID — including services (Thrift `service B extends A`, WSDL 2.0
interface extension, and Cap'n Proto interface inheritance all reference services by identity)
and messages (AsyncAPI reuses one named message across channels, operations, and replies).
Expand Down
30 changes: 27 additions & 3 deletions docs/micro-compiler-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ promotion requires evidence from all three, not two and an expectation.
| Interning + type registry | `compile.Types` | own `types.go` | own `types.go` | already framework |
| Diagnostic accumulation | `compile.Diags` | own `diag.go` | own `diag.go` | already framework |
| Canonical naming grammar | `schema.go` | `naming.go` | `naming.go` | **promoted to `ir`** — `ir.CanonicalWords`, with `compile.NamingFor` the compiler-facing constructor |
| ID grammar | `ids.go` | `ids.go` | `ids.go` | **promoted, derivation left behind** — `compile.TypeID` and friends over a `compile.Space` |
| ID grammar | `ids.go` | `ids.go` | `ids.go` | **promoted, derivation left behind** — `compile.TypeID` and friends over a `compile.Space`, except the primitives: see §3.4 |
| Bounded-recursion guard | `depth`, cap 256 | `depth`, cap 256 | `depth`, cap 32 | **do not promote** — see §3.2 |
| Reference resolution | `resolve.go` | `resolve.go` | — | do not promote |
| Loading, options | yes | yes | yes | do not promote — format-specific |
Expand Down Expand Up @@ -188,6 +188,28 @@ So there is nothing to promote. A helper wrapping three lines that share no stat
indirection to every recursion site and remove nothing, and the per-site degradation — lowered as
any, dropped, unrepresentable — differs at every one of them.

### 3.4 The primitive IDs went past the framework, to `ir`

`t/prim/<kind>` is the one exception to "derivation left behind", and for the reason the rest of the
row states: a compiler's path is its own, so the framework cannot compute one. A primitive has no
path. It derives from no source position at all — its identity is its `PrimKind`, which is an `ir`
type — so `ir.PrimTypeID` is the one ID `ir` *can* derive, and it derives it (#73's second
acceptance bullet).

Placement follows the same argument that took the naming grammar past `compilers/compile` in §3.1,
and it is worth stating because the two look like different cases and are not. What a compiler must
agree on can be enforced by an architecture sweep; what *any producer* must agree on cannot, because
the sweep reaches only this repository's production packages. A `Document` decoded from JSON,
produced by a compiler outside this tree, or rewritten by a pass is held by `irverify` alone, and
`irverify` can only check what `ir` can compute.

The gap that leaves is not hypothetical. `checkIDs` asks an ID to agree with the pointer recorded
beside it, and a primitive records none, so before `ir/prim-id-not-derived` a `string` primitive
interned at `t/openapi/components/schemas/Name` passed clean — and so did one at `t/prim/int32`, an
ID contradicting the node it keys. `ir/prim-space-reserved` closes the converse: the space is
reserved, so a node there that is not a primitive is a collision waiting for the kind whose name it
took.

## 4. The micro-compiler contract

Lowering is a **recursive tree walk, not a linear pipeline.** The source-coordinate → IR-node
Expand Down Expand Up @@ -559,7 +581,9 @@ Two assertions, neither implying the other:
change introduces when it passes the wrong `at`.
- `ID → pointer` injectivity catches a grammar that **collapses** two distinct pointers.

Primitives are excluded: `t/prim/<kind>` is shared and derives from no source position.
Primitives are excluded from both: `t/prim/<kind>` is shared and derives from no source position.
What holds them instead is `ir/prim-id-not-derived`, which asks the ID to agree with the `PrimKind`
it keys rather than with a pointer there is none of — see §3.4.

`irverify` cannot host this as things stand — it is Layer 0 and imports only `ir`, while the grammar
is headed for `compilers/compile`. `internal/harness` is outside the pipeline and can, which is the
Expand Down Expand Up @@ -717,7 +741,7 @@ landing them first would only encode the current one.
| Issue | Disposition |
|---|---|
| #57 archtest cannot enforce compiler isolation | **Closed.** Landed with #161/#143; it was a prerequisite for every package boundary here |
| #73 naming grammar and primitive IDs are cross-compiler ABI in one compiler | **Partly answered, and its own proposal was right about the naming half.** That grammar now lives in `ir` with `irverify` validating against it, which is its first acceptance bullet met as written. The ID grammar went to `compilers/compile` — a compiler's path is its own and nothing in `ir` can compute one — but the `t/prim/<kind>` constructor #73 also asks for is still there, so its second bullet is open and it stays open with it |
| #73 naming grammar and primitive IDs are cross-compiler ABI in one compiler | **Closed, and its own proposal was right about both halves.** The naming grammar lives in `ir` with `irverify` validating against it. The ID grammar stayed in `compilers/compile` — a compiler's path is its own and nothing in `ir` can compute one — but `t/prim/<kind>` is the path there is none of, so `ir.PrimTypeID` went to `ir` with it, and `irverify` holds every producer to it: §3.4 |
| #54 cased `Naming.Hint` passes the neutrality check | **Still open.** 1.3's segmentation work did not reach `Hint`: closing it means changing how hints are derived and regenerating every golden, which is a different change from tightening the checker. The exclusion is now stated in `checkNaming` rather than left to be inferred |
| #83 enforce size and complexity caps in lint | **Closed by 4.2**, deliberately last |
| #66 extract a shared JSON-Schema→IR lowering core before the next compilers land | **Superseded.** Its premise expired — the next compilers landed without it (#20, #21). §3 replaces it with evidence-based promotion. To be closed with that reasoning, not silently |
Expand Down
2 changes: 1 addition & 1 deletion docs/micro-compiler-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ landed with it, in that order, in one pull request.
| ~~#162~~ | **Landed.** Identifier grammar into `compilers/compile`: `compile.TypeID` and friends over a `compile.Space`, with the minted-namespace rule refused by `compile.Types` | — |
| ~~#163~~ | **Landed.** Canonical naming grammar into `compilers/compile`, with `compile.NamingFor` beside it and a conformance suite pinning the boundaries `irverify` cannot see | — |
| ~~#164~~ | **Landed** in three parts: #161 brought `ir/naming-not-words`; `ir/naming-unsegmented` followed for the letter/digit boundary, which a neutral name still carries evidence of; and `ir/naming-not-derived` closed the rest by moving the grammar to `ir` so the verifier can recompute a canonical from its source, which is the only way to see a camel-case boundary. `Hint` (#54) stays out | — |
| #73 | **Partly answered.** Its text proposed `ir` for both halves. The naming grammar went there after all, and `irverify` validates against it — which is exactly its first acceptance bullet. The ID *grammar* went to `compilers/compile` and the `t/prim/<kind>` constructor it also asks for is still there, so its second bullet is open | — |
| ~~#73~~ | **Landed.** Its text proposed `ir` for both halves and was right about both. The naming grammar went there, and `irverify` validates against it. The ID *grammar* stayed in `compilers/compile`, but `t/prim/<kind>` followed the naming half to `ir` as `ir.PrimTypeID` — a primitive has no path for a compiler to own — with `ir/prim-id-not-derived` and `ir/prim-space-reserved` holding every producer to it | — |

#163 changed no output here: #161 had already fixed the segmentation in `compilers/openapi` and
written it into `ir-design.md` §3.2, so the move was measured against a rule already in the
Expand Down
10 changes: 8 additions & 2 deletions internal/harness/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,16 @@ func badExtDoc() *ir.Document {
// the grammar could not have produced is a structural violation, and Check
// returns at the first one. Only their paths carry the ill-formed bytes that make
// two distinct keys collide once JSON coerces them to U+FFFD.
//
// The nodes are Any rather than Primitive for the same reachability reason. A
// primitive's ID is derived from its kind, so a primitive anywhere but
// t/prim/<kind> is a violation of its own, and the fixture would be classified
// before the oracle it exists to reach. Any carries no such rule; the node kind
// is incidental to what this document tests.
func dupKeyDoc() *ir.Document {
return &ir.Document{Types: ir.TypeRegistry{
ir.TypeID("t/x/\xff"): &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/x/\xff"}},
ir.TypeID("t/x/\xfe"): &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/x/\xfe"}},
ir.TypeID("t/x/\xff"): &ir.Any{TypeCommon: ir.TypeCommon{ID: "t/x/\xff"}},
ir.TypeID("t/x/\xfe"): &ir.Any{TypeCommon: ir.TypeCommon{ID: "t/x/\xfe"}},
}}
}

Expand Down
47 changes: 45 additions & 2 deletions ir/ids.go
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,29 @@ const (
// IDSeparator separates an ID's kind, space and path segments.
const IDSeparator = "/"

// IDSpacePrim is the space the primitive leaves are addressed in.
//
// It is the one space that is not some format's own: every compiler must reach
// the same node for the same PrimKind, or two documents lowered from different
// formats disagree about the identity of the same type. A space shared by
// accident rather than on purpose is what naming it here makes visible.
const IDSpacePrim = "prim"

// PrimTypeID returns the shared TypeID of the primitive of kind k.
//
// It is the one ID this package can derive. Every other path is the compiler's
// own — a JSON Pointer, a GraphQL structural path and a protobuf
// fully-qualified name are different things and nothing here can compute one —
// so compilers/compile owns those. A primitive has no source position to derive
// from: its identity is its kind, which is an ir type, so the derivation belongs
// beside it (GitHub #73).
//
// That placement is what lets irverify hold every Document to this ID rather
// than only the ones this repository's compilers produce.
func PrimTypeID(k PrimKind) TypeID {
return TypeID(IDKindType + IDSeparator + IDSpacePrim + IDSeparator + string(k))
}

// WellFormedID reports whether id has the shape kind requires: the kind prefix,
// a non-empty space, and an optional path, with no empty segment before the
// path. A space with no path is an ID in its own right — the space names one
Expand All @@ -75,13 +98,33 @@ func WellFormedID(kind, id string) bool {
return !hasPath || path != ""
}

// IDSpace returns the space segment of a well-formed id — the segment between
// the kind and the path — and whether id is well-formed at all. An ID that is
// not yields no space rather than a guess at one.
func IDSpace(kind, id string) (string, bool) {
rest, ok := idRest(kind, id)
if !ok {
return "", false
}
space, _, _ := strings.Cut(rest, IDSeparator)
return space, true
}

// IDPath returns the path segment of a well-formed id — everything after the
// kind and the space — and whether id carries one at all.
func IDPath(kind, id string) (string, bool) {
if !WellFormedID(kind, id) {
rest, ok := idRest(kind, id)
if !ok {
return "", false
}
rest := strings.TrimPrefix(id, kind+IDSeparator)
_, path, hasPath := strings.Cut(rest, IDSeparator)
return path, hasPath
}

// idRest returns everything after a well-formed id's kind prefix.
func idRest(kind, id string) (string, bool) {
if !WellFormedID(kind, id) {
return "", false
}
return strings.TrimPrefix(id, kind+IDSeparator), true
}
76 changes: 76 additions & 0 deletions ir/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,82 @@ func TestWellFormedID_Shape(t *testing.T) {
}
}

// TestPrimTypeID_IsTheSharedScheme pins the spelling every compiler must reach
// for a primitive. These IDs are written into every golden IR snapshot and are
// the one identity two documents lowered from different formats have to agree
// on, so a change here is a silent breaking change across formats.
func TestPrimTypeID_IsTheSharedScheme(t *testing.T) {
t.Parallel()
assert.Equal(t, ir.TypeID("t/prim/string"), ir.PrimTypeID(ir.PrimString))
assert.Equal(t, ir.TypeID("t/prim/datetime_offset"), ir.PrimTypeID(ir.PrimDatetimeOffset),
"a multi-word kind is spelled as its constant, not re-cased")
}

// TestPrimTypeID_IsWellFormedAndInjective walks the whole primitive vocabulary
// rather than a sample of it. Two properties have to hold for every kind, and a
// sample cannot show either: the ID is one the grammar produces — irverify
// rejects every document otherwise — and distinct kinds do not collide, since
// two kinds sharing an ID would make the node reached for one of them depend on
// which was interned first.
func TestPrimTypeID_IsWellFormedAndInjective(t *testing.T) {
t.Parallel()
byID := make(map[ir.TypeID]ir.PrimKind, len(primKindSpellings))
for kind := range primKindSpellings {
id := ir.PrimTypeID(kind)
require.True(t, ir.WellFormedID(ir.IDKindType, string(id)),
"%q is not an ID the grammar produces", id)

space, ok := ir.IDSpace(ir.IDKindType, string(id))
require.True(t, ok)
assert.Equal(t, ir.IDSpacePrim, space, "%q is addressed outside the shared space", id)

path, has := ir.IDPath(ir.IDKindType, string(id))
require.True(t, has)
assert.Equal(t, string(kind), path, "the path is the kind and nothing else")

if other, clash := byID[id]; clash {
t.Errorf("kinds %q and %q share the ID %q", kind, other, id)
}
byID[id] = kind
}
assert.Len(t, byID, len(primKindSpellings), "every kind reaches an ID of its own")
}

// TestIDSpace_Extraction pins what an ID's space is, including the answers that
// are not one: a malformed ID has no space to report, and neither has an ID
// carrying another kind's prefix.
func TestIDSpace_Extraction(t *testing.T) {
t.Parallel()
tests := []struct {
name string
kind string
id string
want string
wantOK bool
}{
{
name: "the segment before the path", kind: ir.IDKindType,
id: "t/openapi/components/schemas/User", want: "openapi", wantOK: true,
},
{name: "a space-only ID is all space", kind: ir.IDKindType, id: "t/prim", want: "prim", wantOK: true},
{name: "a path with its own separators", kind: ir.IDKindProp, id: "p/openapi/a/b/c", want: "openapi", wantOK: true},
{name: "an empty space reports none", kind: ir.IDKindType, id: "t//x"},
{name: "an empty path reports none", kind: ir.IDKindType, id: "t/openapi/"},
{name: "a wrong-kind ID reports none", kind: ir.IDKindType, id: "op/openapi/x"},
{name: "an empty ID reports none", kind: ir.IDKindType, id: ""},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
t.Parallel()
got, ok := ir.IDSpace(tc.kind, tc.id)
if got != tc.want || ok != tc.wantOK {
t.Errorf("IDSpace(%q, %q) = (%q, %v), want (%q, %v)",
tc.kind, tc.id, got, ok, tc.want, tc.wantOK)
}
})
}
}

// TestIDPath_Extraction pins what an ID's path is, including the two answers
// that are not a path: a malformed ID has none to report, and a space-only ID
// carries none by construction.
Expand Down
Loading
Loading