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
2 changes: 1 addition & 1 deletion compilers/compilers_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
50 changes: 50 additions & 0 deletions docs/ir-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 8 additions & 6 deletions internal/harness/internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")},
}}
}
Expand All @@ -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) {
Expand Down
2 changes: 1 addition & 1 deletion internal/harness/order_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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))
Expand Down
19 changes: 19 additions & 0 deletions ir/document.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
31 changes: 30 additions & 1 deletion ir/document_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 1 addition & 1 deletion ir/irtest/golden_internal_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion ir/irtest/golden_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,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))
Expand Down
12 changes: 6 additions & 6 deletions ir/irverify/bigval_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,19 +29,19 @@ func numericValue(literal string) ir.Value {
func bigValCarriers(literal string) map[string]*ir.Document {
prov := ir.Provenance{Source: ir.NoSource}
scalar := func(c *ir.Constraints) *ir.Document {
return &ir.Document{Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
TypeCommon: ir.TypeCommon{ID: "t/x/S", Name: named("s"), Provenance: prov},
Constraints: c,
}}}
}
valued := func(v ir.Value) *ir.Document {
return &ir.Document{Types: ir.TypeRegistry{"t/x/L": &ir.Literal{
return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/L": &ir.Literal{
TypeCommon: ir.TypeCommon{ID: "t/x/L", Name: named("l"), Provenance: prov},
Value: v,
}}}
}
property := func(p ir.Property) *ir.Document {
return &ir.Document{Types: ir.TypeRegistry{"t/x/M": &ir.Model{
return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/M": &ir.Model{
TypeCommon: ir.TypeCommon{ID: "t/x/M", Name: named("m"), Provenance: prov},
Properties: []ir.Property{p},
}}}
Expand Down Expand Up @@ -136,13 +136,13 @@ func TestVerify_CanonicalBigValIsClean(t *testing.T) {
func TestVerify_UnusedNumIsNotABound(t *testing.T) {
t.Parallel()
prov := ir.Provenance{Source: ir.NoSource}
unused := &ir.Document{Types: ir.TypeRegistry{"t/x/L": &ir.Literal{
unused := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/L": &ir.Literal{
TypeCommon: ir.TypeCommon{ID: "t/x/L", Name: named("l"), Provenance: prov},
Value: ir.Value{Kind: ir.ValueString, Str: "not a number"},
}}}
assert.Empty(t, irverify.Verify(unused), "a non-numeric Value carries no literal to check")

empty := &ir.Document{Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
empty := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
TypeCommon: ir.TypeCommon{ID: "t/x/S", Name: named("s"), Provenance: prov},
Constraints: &ir.Constraints{Min: bigVal("")},
}}}
Expand All @@ -157,7 +157,7 @@ func TestVerify_UnusedNumIsNotABound(t *testing.T) {
// literal would report every document in the corpus.
func TestVerify_AbsentBoundIsClean(t *testing.T) {
t.Parallel()
doc := &ir.Document{Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
doc := &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{"t/x/S": &ir.Scalar{
TypeCommon: ir.TypeCommon{ID: "t/x/S", Name: named("s"), Provenance: ir.Provenance{Source: ir.NoSource}},
Constraints: &ir.Constraints{Pattern: "^[a-z]+$"},
}}}
Expand Down
7 changes: 4 additions & 3 deletions ir/irverify/doc.go
Original file line number Diff line number Diff line change
@@ -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
20 changes: 10 additions & 10 deletions ir/irverify/ids_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
Expand All @@ -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")
Expand All @@ -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")
}

Expand All @@ -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))
}

Expand Down Expand Up @@ -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")
Expand All @@ -138,7 +138,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}},
}}

Expand Down Expand Up @@ -172,7 +172,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")
})
}
Expand All @@ -190,7 +190,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}
Expand Down Expand Up @@ -224,7 +224,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")
}

Expand All @@ -233,7 +233,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",
Expand Down
1 change: 1 addition & 0 deletions ir/irverify/irverify.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ func Verify(doc *ir.Document) []Violation {
vs = append(vs, checkPrimKinds(doc)...)
vs = append(vs, checkAuthKinds(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
Expand Down
4 changes: 2 additions & 2 deletions ir/irverify/kinds_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import (
// nothing to say about it and only the new claim can fire.
func primDoc(kind ir.PrimKind) *ir.Document {
id := ir.PrimTypeID(kind)
return &ir.Document{Types: ir.TypeRegistry{id: &ir.Primitive{
return &ir.Document{IRVersion: ir.IRVersion, Types: ir.TypeRegistry{id: &ir.Primitive{
TypeCommon: ir.TypeCommon{ID: id, Provenance: ir.Provenance{Source: ir.NoSource}},
Prim: kind,
}}}
Expand Down Expand Up @@ -66,7 +66,7 @@ func TestVerify_DeclaredPrimKindIsClean(t *testing.T) {
// question.
func authDoc(kind ir.AuthKind) *ir.Document {
const id ir.AuthID = "auth/openapi/components/securitySchemes/token"
return &ir.Document{Auth: map[ir.AuthID]ir.AuthScheme{id: {
return &ir.Document{IRVersion: ir.IRVersion, Auth: map[ir.AuthID]ir.AuthScheme{id: {
ID: id, Name: named("token"), Kind: kind,
Provenance: ir.Provenance{Source: ir.NoSource},
}}}
Expand Down
6 changes: 5 additions & 1 deletion ir/irverify/naming_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Loading
Loading