From cc76193f9c8445d3ab01943bc5b151f6c4ad1524 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 03:42:07 +0300 Subject: [PATCH] feat(ir): enforce irVersion and record the compatibility policy --- compilers/compilers_test.go | 2 +- docs/ir-design.md | 50 ++++++++++++++++++ internal/harness/internal_test.go | 14 ++--- internal/harness/order_test.go | 2 +- ir/document.go | 19 +++++++ ir/document_test.go | 31 ++++++++++- ir/irtest/golden_internal_test.go | 2 +- ir/irtest/golden_test.go | 2 +- ir/irverify/doc.go | 7 +-- ir/irverify/ids_test.go | 20 +++---- ir/irverify/irverify.go | 1 + ir/irverify/naming_test.go | 6 ++- ir/irverify/version.go | 48 +++++++++++++++++ ir/irverify/version_test.go | 87 +++++++++++++++++++++++++++++++ ir/json_test.go | 2 +- 15 files changed, 267 insertions(+), 26 deletions(-) create mode 100644 ir/irverify/version.go create mode 100644 ir/irverify/version_test.go diff --git a/compilers/compilers_test.go b/compilers/compilers_test.go index aa2afb5..fd3b667 100644 --- a/compilers/compilers_test.go +++ b/compilers/compilers_test.go @@ -17,7 +17,7 @@ type stubCompiler struct{ formats []compilers.SourceFormat } func (s *stubCompiler) Formats() []compilers.SourceFormat { return s.formats } func (s *stubCompiler) Compile(_ context.Context, _ []compilers.Source, _ compilers.Options) (*ir.Document, []ir.Diagnostic, error) { - return &ir.Document{IRVersion: "0.1.0"}, nil, nil + return &ir.Document{IRVersion: ir.IRVersion}, nil, nil } func TestRegistry_RegisterAndLookup(t *testing.T) { diff --git a/docs/ir-design.md b/docs/ir-design.md index a72ceea..d19e8c9 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -83,6 +83,56 @@ type TagDef struct { Name string; Docs Docs } A `Document` is self-contained: no node references anything outside it. +### 2.1 `IRVersion` — the schema stamp and the compatibility policy + +`IRVersion` names the generation of the IR *schema* — the shape of the document itself, not the +API it describes (that is `Version`) and not the commit that produced it. It is the one claim in a +document that a reader cannot recompute from the contents: everything else about a document can be +checked against the document, but which spelling of the schema its keys are in has to be declared. + +**What moves it.** Any change to the JSON shape of a `Document`: a key renamed or removed, an +encoding changed, or the meaning of an existing key changed. A line of work that changes the shape +several times bumps it once, where it lands on `main` — a version that moves within an unmerged +branch tells a consumer nothing and rewrites every golden each time it moves. `ir.IRVersion` is the +constant; its GoDoc carries the log of what each past bump changed. + +**What a bump implies.** Pre-1.0 (`0.MINOR.PATCH`), MINOR is the breaking position and every bump +so far has been breaking. There is no non-breaking bump in the history and nothing distinguishes +one, so PATCH carries no promise a consumer may read compatibility into. Moving off `0.` is a +decision about the project's stability rather than about any one shape change, and no policy for +MAJOR is written here until that decision is taken. + +- *Compilers* stamp `ir.IRVersion` on every document they produce. That is the whole obligation: + a compiler never emits an older generation, and there is no option to ask it to. +- *Emitters* and any other consumer are built against exactly one generation. A bump is a change + they must be updated for; there is no "read it anyway" mode, because the failure a stale + consumer produces is silent — it finds no key it recognizes where a renamed one used to be and + drops the construct rather than reporting it. + +**What a consumer does on mismatch: refuse.** `ir.CompatibleVersion(v)` is the predicate, and it is +exact equality with the `ir.IRVersion` the consumer was compiled against. A differing patch, a +prerelease suffix, and a value that is not a version at all are all equally unreadable; accepting a +neighbouring version would mean claiming to know what changed between them, which is the knowledge +a version exists because nobody has. Morphic ships **no migration path** between generations — a +document written by another generation is re-compiled from its source spec, not converted. + +**Where it is enforced.** In `irverify`, which is the gate every consumer of a document runs before +trusting it, whether the document was just compiled in memory or decoded from JSON. Two codes, +because the two failures name different writers: `ir/ir-version-absent` for a document carrying no +stamp — a producer that forgot, and the failure `omitempty` hides best, since a document without +the key is byte-identical to one that never had it — and `ir/ir-version-incompatible` for a stamp +this build does not read. Nothing in the repository decodes a persisted `Document` today, so there +is no separate loader to attach the check to; when one is written, `ir.CompatibleVersion` is what +it calls, and it should refuse before interpreting any other field. + +**Consequence for goldens.** Every committed IR golden embeds `irVersion`, so a bump rewrites the +whole snapshot corpus in the same change that makes it. Confirm rather than trust: + +```bash +ls testdata/*/openapi/*.golden.json | wc -l +grep -l '"irVersion"' testdata/*/openapi/*.golden.json | wc -l +``` + --- ## 3. Identity, names, references diff --git a/internal/harness/internal_test.go b/internal/harness/internal_test.go index a10279d..a215ae7 100644 --- a/internal/harness/internal_test.go +++ b/internal/harness/internal_test.go @@ -23,7 +23,7 @@ import ( // is the behaviour under test; dupKeyDoc is the fixture for Check's round-trip // outcome. func badExtDoc() *ir.Document { - return &ir.Document{Unmodeled: ir.Unmodeled{ + return &ir.Document{IRVersion: ir.IRVersion, Unmodeled: ir.Unmodeled{ "openapi:x": {Reason: ir.ReasonVendorExtension, Value: ir.RawValue("{invalid")}, }} } @@ -46,24 +46,26 @@ func badExtDoc() *ir.Document { // // The names are load-bearing for the same reason the IDs are: a node with no // name in any channel is a structural violation, and Check would classify this -// document as one before the round-trip oracle ever ran. +// document as one before the round-trip oracle ever ran. The schema stamp is +// load-bearing on the same terms. func dupKeyDoc() *ir.Document { named := ir.Naming{Source: "node", Canonical: "node"} - return &ir.Document{Types: ir.TypeRegistry{ + return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{ ir.TypeID("t/x/\xff"): &ir.Any{TypeCommon: ir.TypeCommon{ID: "t/x/\xff", Name: named}}, ir.TypeID("t/x/\xfe"): &ir.Any{TypeCommon: ir.TypeCommon{ID: "t/x/\xfe", Name: named}}, }} } // soundDoc returns a minimal, structurally-sound document: one model keyed by its -// own ID with a neutral canonical name. It has no violations and round-trips -// through JSON cleanly, so the oracles reach the step under test. +// own ID with a neutral canonical name, stamped with the IR schema version this +// build writes. It has no violations and round-trips through JSON cleanly, so the +// oracles reach the step under test. func soundDoc() *ir.Document { m := &ir.Model{TypeCommon: ir.TypeCommon{ ID: "t/x/Model", Name: ir.Naming{Source: "Model", Canonical: "model"}, }} - return &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{m.ID: m}} } func TestRoundTrips_MarshalError(t *testing.T) { diff --git a/internal/harness/order_test.go b/internal/harness/order_test.go index 8b37b13..7a60183 100644 --- a/internal/harness/order_test.go +++ b/internal/harness/order_test.go @@ -450,7 +450,7 @@ func TestCheck_OrderDependentOutcome(t *testing.T) { Name: ir.Naming{Source: "M", Canonical: "m"}, Provenance: ir.Provenance{Pointer: "/" + path}, }} - return &ir.Document{Types: ir.TypeRegistry{m.ID: m}}, nil, nil + return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{m.ID: m}}, nil, nil } r := Check(context.Background(), "spec", []byte(src)) diff --git a/ir/document.go b/ir/document.go index 2c8d609..d1de046 100644 --- a/ir/document.go +++ b/ir/document.go @@ -21,6 +21,25 @@ package ir // recognizes and drops every unmodeled construct in silence. const IRVersion = "0.3.0" +// CompatibleVersion reports whether a document stamped version can be read by +// this build. It is the predicate behind the compatibility policy in +// ir-design §2.1, and what a consumer holding a decoded document asks before +// interpreting any other field in it. +// +// The comparison is exact. Every bump this constant has taken changed the JSON +// shape, so there is no looser relation to admit: a differing patch, a +// prerelease suffix, and a value that is not a version at all are equally +// unreadable. Accepting a neighbouring version would mean claiming to know what +// changed between the two, which is the knowledge a version exists because +// nobody has. +// +// An empty version is incompatible too, but a caller that can act on the +// difference should test for it separately: absence is a producer that never +// stamped the document, while an unrecognized stamp is a fault in the pairing. +func CompatibleVersion(version string) bool { + return version == IRVersion +} + // TypeRegistry is the flat, ID-keyed owner of every TypeDef in a Document // (ir-design §2, §4); every other node references types by TypeID. JSON // (un)marshaling of the sealed sum is defined with the rest of the sum-type diff --git a/ir/document_test.go b/ir/document_test.go index a00a466..a4ba07c 100644 --- a/ir/document_test.go +++ b/ir/document_test.go @@ -13,7 +13,7 @@ func TestDocument_ConstructRepresentative(t *testing.T) { t.Parallel() userID := ir.TypeID("t/openapi/components/schemas/User") doc := ir.Document{ - IRVersion: "0.1.0", + IRVersion: ir.IRVersion, Name: "Petstore", Version: "1.0.0", Types: ir.TypeRegistry{ @@ -67,6 +67,35 @@ func TestDocument_ConstructRepresentative(t *testing.T) { assert.False(t, model.Properties[0].Type.Nullable) } +// TestCompatibleVersion pins the pre-1.0 compatibility policy (ir-design §2.1): +// a document is readable only when its stamp is character-for-character the +// version this build was compiled against. Every recorded bump so far changed +// the JSON shape, so nothing licenses accepting a neighbouring one — not a +// differing patch, not the same version spelled differently. +func TestCompatibleVersion(t *testing.T) { + t.Parallel() + tests := []struct { + name string + version string + want bool + }{ + {"this build's version", ir.IRVersion, true}, + {"absent", "", false}, + {"an earlier generation", "0.1.0", false}, + {"a later generation", "0.4.0", false}, + {"a differing patch", "0.3.1", false}, + {"a prerelease of this version", ir.IRVersion + "-rc.1", false}, + {"padded with whitespace", " " + ir.IRVersion + " ", false}, + {"not a version at all", "99.99.99-bogus", false}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + assert.Equal(t, tc.want, ir.CompatibleVersion(tc.version)) + }) + } +} + // TestDocument_ChannelsDeterministic pins Class C for Document's map-keyed // registry fields: Channels must marshal with keys in sorted order on every // run. diff --git a/ir/irtest/golden_internal_test.go b/ir/irtest/golden_internal_test.go index dcddcc7..42cc67f 100644 --- a/ir/irtest/golden_internal_test.go +++ b/ir/irtest/golden_internal_test.go @@ -68,7 +68,7 @@ func TestCompareGolden_UpdateWritesAndReturns(t *testing.T) { // Not parallel: toggles the shared -update flag. dir := t.TempDir() path := filepath.Join(dir, "nested", "doc.golden.json") - doc := &ir.Document{IRVersion: "0.1.0", Name: "u"} + doc := &ir.Document{IRVersion: ir.IRVersion, Name: "u"} withUpdate(t, true, func() { rec := runCompare(path, doc) diff --git a/ir/irtest/golden_test.go b/ir/irtest/golden_test.go index 2afe552..2f05bcf 100644 --- a/ir/irtest/golden_test.go +++ b/ir/irtest/golden_test.go @@ -16,7 +16,7 @@ func TestCompareGolden_WritesThenMatches(t *testing.T) { // Not parallel: exercises the -update path via WriteGolden. dir := t.TempDir() path := filepath.Join(dir, "doc.golden.json") - doc := &ir.Document{IRVersion: "0.1.0", Name: "g", Version: "1"} + doc := &ir.Document{IRVersion: ir.IRVersion, Name: "g", Version: "1"} // First write the golden explicitly, then compare against it. require.NoError(t, irtest.WriteGolden(path, doc)) diff --git a/ir/irverify/doc.go b/ir/irverify/doc.go index 3bb2870..c2cb488 100644 --- a/ir/irverify/doc.go +++ b/ir/irverify/doc.go @@ -1,7 +1,8 @@ // Package irverify checks a compiled ir.Document against the structural // invariants every compiler must uphold (stable IDs, no two nodes claiming one // identity, no dangling references, neutral naming, routable Unmodeled entries, -// in-range provenance). Its findings are Violation values — our own compiler -// bugs, deliberately a separate channel from ir.Diagnostic, which reports -// problems in the source spec. Verify is pure and imports only ir. +// in-range provenance, a readable schema stamp). Its findings are Violation +// values — our own compiler bugs, deliberately a separate channel from +// ir.Diagnostic, which reports problems in the source spec. Verify is pure and +// imports only ir. package irverify diff --git a/ir/irverify/ids_test.go b/ir/irverify/ids_test.go index 7c5931b..4d0f898 100644 --- a/ir/irverify/ids_test.go +++ b/ir/irverify/ids_test.go @@ -33,7 +33,7 @@ func TestVerify_MalformedTypeIDIsAViolation(t *testing.T) { m := &ir.Model{TypeCommon: ir.TypeCommon{ ID: tc.id, Name: ir.Naming{Source: "M", Canonical: "m"}, }} - doc := &ir.Document{Types: ir.TypeRegistry{tc.id: m}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{tc.id: m}} assert.Contains(t, violationCodes(irverify.Verify(doc)), "ir/id-malformed", "%q is not an ID the grammar produces", tc.id) }) @@ -52,7 +52,7 @@ func TestVerify_IDDisagreeingWithItsPointerIsAViolation(t *testing.T) { Name: ir.Naming{Source: "Addr", Canonical: "addr"}, Provenance: ir.Provenance{Pointer: "addr"}, }} - doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{m.ID: m}} got := irverify.Verify(doc) assert.Contains(t, violationCodes(got), "ir/id-provenance-disagreement") @@ -71,7 +71,7 @@ func TestVerify_WrongPointerIsAViolation(t *testing.T) { Name: ir.Naming{Source: "Child", Canonical: "child"}, Provenance: ir.Provenance{Pointer: "/components/schemas/Parent"}, }} - doc := &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{m.ID: m}} assert.Contains(t, violationCodes(irverify.Verify(doc)), "ir/id-provenance-disagreement") } @@ -83,7 +83,7 @@ func TestVerify_WrongPointerIsAViolation(t *testing.T) { func TestVerify_PointerlessIDIsClean(t *testing.T) { t.Parallel() p := &ir.Primitive{TypeCommon: ir.TypeCommon{ID: "t/prim/string"}, Prim: ir.PrimString} - doc := &ir.Document{Types: ir.TypeRegistry{p.ID: p}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{p.ID: p}} assert.Empty(t, irverify.Verify(doc)) } @@ -116,7 +116,7 @@ func TestVerify_PrimitiveAwayFromItsSharedIDIsAViolation(t *testing.T) { t.Run(tc.name, func(t *testing.T) { t.Parallel() p := &ir.Primitive{TypeCommon: ir.TypeCommon{ID: tc.id}, Prim: tc.kind} - doc := &ir.Document{Types: ir.TypeRegistry{tc.id: p}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{tc.id: p}} got := irverify.Verify(doc) assert.Contains(t, violationCodes(got), "ir/prim-id-not-derived") @@ -134,7 +134,7 @@ func TestVerify_PrimitiveAwayFromItsSharedIDIsAViolation(t *testing.T) { func TestVerify_KindlessPrimitiveIsReportedOnItsOwnTerms(t *testing.T) { t.Parallel() const id ir.TypeID = "t/openapi/components/schemas/Name" - doc := &ir.Document{Types: ir.TypeRegistry{ + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{ id: &ir.Primitive{TypeCommon: ir.TypeCommon{ID: id}}, }} @@ -167,7 +167,7 @@ func TestVerify_NonPrimitiveInThePrimSpaceIsAViolation(t *testing.T) { m := &ir.Model{TypeCommon: ir.TypeCommon{ ID: tc.id, Name: ir.Naming{Source: "M", Canonical: "m"}, }} - doc := &ir.Document{Types: ir.TypeRegistry{tc.id: m}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{tc.id: m}} assert.Contains(t, violationCodes(irverify.Verify(doc)), "ir/prim-space-reserved") }) } @@ -185,7 +185,7 @@ func TestVerify_NonPrimitiveInThePrimSpaceIsAViolation(t *testing.T) { // fails here. func TestVerify_PrimIDChecksAreScopedToTheSpaceAndTheKind(t *testing.T) { t.Parallel() - doc := &ir.Document{Types: ir.TypeRegistry{}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{}} for _, kind := range []ir.PrimKind{ir.PrimString, ir.PrimInt32, ir.PrimDatetimeOffset, ir.PrimAny} { id := ir.PrimTypeID(kind) doc.Types[id] = &ir.Primitive{TypeCommon: ir.TypeCommon{ID: id}, Prim: kind} @@ -219,7 +219,7 @@ func TestVerify_AuthIDIsHeldToTheSameRule(t *testing.T) { Name: ir.Naming{Source: "apiKey", Canonical: "api_key"}, Provenance: ir.Provenance{Pointer: "/components/securitySchemes/apiKey"}, } - doc := &ir.Document{Auth: map[ir.AuthID]ir.AuthScheme{scheme.ID: scheme}} + doc := &ir.Document{IRVersion: ir.IRVersion, Auth: map[ir.AuthID]ir.AuthScheme{scheme.ID: scheme}} assert.Contains(t, violationCodes(irverify.Verify(doc)), "ir/id-provenance-disagreement") } @@ -228,7 +228,7 @@ func TestVerify_AuthIDIsHeldToTheSameRule(t *testing.T) { // pointer-derived spaces a compiler addresses and a minted one. func TestVerify_DerivedIDsAreClean(t *testing.T) { t.Parallel() - doc := &ir.Document{Types: ir.TypeRegistry{}} + doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{}} for _, id := range []ir.TypeID{ "t/openapi/components/schemas/User", "t/anon/paths/~1pets/get/responses/200/content/application~1json/schema", diff --git a/ir/irverify/irverify.go b/ir/irverify/irverify.go index ec779b6..2720fcd 100644 --- a/ir/irverify/irverify.go +++ b/ir/irverify/irverify.go @@ -32,6 +32,7 @@ func Verify(doc *ir.Document) []Violation { vs = append(vs, checkIDs(doc)...) vs = append(vs, checkPrimIDs(doc)...) vs = append(vs, checkDiagnostics(doc)...) + vs = append(vs, checkVersion(doc)...) vs = append(vs, runWalkChecks(doc)...) // Stable: two violations can share a (Code, Path) — an embedded field diff --git a/ir/irverify/naming_test.go b/ir/irverify/naming_test.go index 604beb3..898dcd6 100644 --- a/ir/irverify/naming_test.go +++ b/ir/irverify/naming_test.go @@ -18,9 +18,13 @@ func canonicalOnly(canon string) *ir.Document { return modelNamed(ir.Naming{Canonical: canon}) } +// modelNamed builds a document holding one model under the given Naming. It +// carries the schema stamp for the same reason the model carries an ID keyed to +// itself: a document missing either has a violation of its own, and a fixture +// about naming must contribute none. func modelNamed(n ir.Naming) *ir.Document { m := &ir.Model{TypeCommon: ir.TypeCommon{ID: "t/x/M", Name: n}} - return &ir.Document{Types: ir.TypeRegistry{m.ID: m}} + return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{m.ID: m}} } func TestVerify_NeutralCanonicalIsClean(t *testing.T) { diff --git a/ir/irverify/version.go b/ir/irverify/version.go new file mode 100644 index 0000000..05d8257 --- /dev/null +++ b/ir/irverify/version.go @@ -0,0 +1,48 @@ +package irverify + +import ( + "github.com/dexpace/morphic/ir" +) + +// versionPath is where a violation about the schema stamp is reported, spelled +// as ir.WalkValues would reach the field. +const versionPath = ir.DocumentPath + ".IRVersion" + +// checkVersion asserts the document declares the IR schema generation it was +// written against, and that it is one this build reads (ir-design §2.1). +// +// Every other check here holds a document against itself: keys against node IDs, +// references against registries, canonical names against the grammar that mints +// them. This one holds it against the schema it claims to conform to — the only +// claim in a document that cannot be recomputed from its contents, and so the +// only one a consumer has to be told. A document whose every internal invariant +// holds is still unreadable if the generation that wrote it spelled its keys +// differently, and nothing else in this package can see that. +// +// Two codes rather than one, because the failures name different writers. +// Absence is a producer that never stamped the document — ours, since the +// compilers in this tree are what stamp it — and it is the failure omitempty +// hides, a document without the key being byte-identical to one that never +// carried it. An incompatible stamp is another generation's document reaching a +// consumer that cannot read it, which is a fault in the pairing. +// +// It takes no declarations and runs no walk: the field is on Document itself, +// and a walk to read one field would report a truncation flag that says nothing +// about it. +func checkVersion(doc *ir.Document) []Violation { + if doc.IRVersion == "" { + return []Violation{{ + Code: "ir/ir-version-absent", + Message: "document declares no irVersion; this build writes and reads " + ir.IRVersion, + Path: versionPath, + }} + } + if !ir.CompatibleVersion(doc.IRVersion) { + return []Violation{{ + Code: "ir/ir-version-incompatible", + Message: "document declares irVersion " + doc.IRVersion + "; this build reads only " + ir.IRVersion, + Path: versionPath, + }} + } + return nil +} diff --git a/ir/irverify/version_test.go b/ir/irverify/version_test.go new file mode 100644 index 0000000..d08b128 --- /dev/null +++ b/ir/irverify/version_test.go @@ -0,0 +1,87 @@ +package irverify_test + +import ( + "encoding/json" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irverify" +) + +// TestVerify_AbsentIRVersionIsAViolation asserts a document carrying no schema +// stamp is reported. Absence is the producer's defect — a compiler that never +// set the field — and it is the one an unstamped document hides best, because +// the JSON key is omitempty and so a document without it is byte-identical to +// one that never had it. +func TestVerify_AbsentIRVersionIsAViolation(t *testing.T) { + doc := validDoc() + doc.IRVersion = "" + + got := irverify.Verify(doc) + require.Len(t, got, 1) + assert.Equal(t, "ir/ir-version-absent", got[0].Code) + assert.Equal(t, "doc.IRVersion", got[0].Path) +} + +// TestVerify_IncompatibleIRVersionIsAViolation asserts a stamp this build does +// not read is reported, whether it names a real other schema generation or is +// not a version at all. The policy is exact equality (ir-design §2.1), so both +// reach the same code: neither can be interpreted, and a consumer has the same +// one move in either case. +func TestVerify_IncompatibleIRVersionIsAViolation(t *testing.T) { + tests := []struct { + name string + version string + }{ + {"older generation", "0.1.0"}, + {"newer generation", "99.0.0"}, + {"not a version", "99.99.99-bogus"}, + {"whitespace around the current version", " 0.3.0 "}, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + doc := validDoc() + doc.IRVersion = tc.version + + got := irverify.Verify(doc) + require.Len(t, got, 1) + assert.Equal(t, "ir/ir-version-incompatible", got[0].Code) + assert.Equal(t, "doc.IRVersion", got[0].Path) + assert.Contains(t, got[0].Message, tc.version) + }) + } +} + +// TestVerify_CurrentIRVersionIsClean holds the rule to firing on the documents +// it is meant to pass. Without it the check could reject every document and +// every other test here would still read as if it worked. +func TestVerify_CurrentIRVersionIsClean(t *testing.T) { + doc := validDoc() + require.Equal(t, ir.IRVersion, doc.IRVersion) + + assert.Empty(t, irverify.Verify(doc)) +} + +// TestVerify_StampedDocumentRoundTripsClean asserts the check leaves invariant 7 +// intact: a valid document still survives the JSON round trip byte-for-byte, and +// the decoded document verifies as clean. The stamp is the one field a round trip +// could drop without any other check noticing, since omitempty erases an empty +// one on the way out. +func TestVerify_StampedDocumentRoundTripsClean(t *testing.T) { + doc := validDoc() + + encoded, err := json.Marshal(doc) + require.NoError(t, err) + + var decoded ir.Document + require.NoError(t, json.Unmarshal(encoded, &decoded)) + assert.Equal(t, ir.IRVersion, decoded.IRVersion) + assert.Empty(t, irverify.Verify(&decoded)) + + reencoded, err := json.Marshal(&decoded) + require.NoError(t, err) + assert.JSONEq(t, string(encoded), string(reencoded)) +} diff --git a/ir/json_test.go b/ir/json_test.go index c7e9080..dcbaf92 100644 --- a/ir/json_test.go +++ b/ir/json_test.go @@ -36,7 +36,7 @@ func sampleDocument(t *testing.T) ir.Document { id, td := mk("t/k/"+string(entry.Kind()), entry) types[id] = td } - return ir.Document{IRVersion: "0.1.0", Name: "kinds", Version: "1", Types: types} + return ir.Document{IRVersion: ir.IRVersion, Name: "kinds", Version: "1", Types: types} } func TestDocument_JSONRoundTripAllKinds(t *testing.T) {