From 72d141dce741c648d0b8387979d3acb0849b7ae1 Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Sun, 9 Aug 2026 06:32:03 +0300 Subject: [PATCH 1/2] feat(irverify): hold Naming.Aliases to a rule Naming has four channels and irverify read three. Aliases was read by nothing anywhere in the pipeline -- it appeared in production code exactly once, at its own declaration -- so a model whose aliases were empty and duplicated verified clean and validated clean. Which rules apply had to be settled first, and the answer is not Canonical's. An alias is matched against a name some other schema wrote: an Avro alias is a full name such as com.example.User, and neutralizing it to words discards the separators and the casing the match is made of. That makes it a verbatim channel like Source rather than a neutral one like Canonical, and holding it to the neutrality rules would be the lossy direction invariant 2 forbids. The IR is not deciding this spelling, it is recording one. What is left is decidable without a grammar. An empty alias matches nothing and a repeated one matches twice, so neither can be what a producer intended: both say a list was built wrong rather than that a name was spelled wrong. checkNaming reports each as its own violation, at a path naming the offending entry, and the field's doc comment now states what a well-formed entry looks like instead of only what the field is for. --- ir/irverify/naming.go | 68 ++++++++++++++++++++--- ir/irverify/naming_alias_test.go | 93 ++++++++++++++++++++++++++++++++ ir/naming.go | 10 ++++ 3 files changed, 165 insertions(+), 6 deletions(-) create mode 100644 ir/irverify/naming_alias_test.go diff --git a/ir/irverify/naming.go b/ir/irverify/naming.go index 9899a116..0e437eec 100644 --- a/ir/irverify/naming.go +++ b/ir/irverify/naming.go @@ -2,6 +2,7 @@ package irverify import ( "reflect" + "strconv" "strings" "unicode" @@ -52,12 +53,16 @@ var nameOptional = map[reflect.Type]bool{ // vacuously true of the empty string: an entirely empty Naming satisfied all // three while leaving an emitter nothing to name the entity by (GitHub #251). // -// Only Canonical is checked for content. Naming.Hint — the generated-name +// Only Canonical is checked for neutrality. Naming.Hint — the generated-name // channel — is held to none of the content rules, so casing // and punctuation still reach the IR through it. That is GitHub #54, left open // deliberately: closing it means changing how the compilers derive hints and // regenerating every golden, which is a different change from tightening this // checker. +// +// Naming.Aliases is held instead to the two rules that need no neutrality — +// non-empty and non-repeating — because an alias is a verbatim channel like +// Source rather than a neutral one like Canonical. See appendAliasViolations. func checkNaming(doc *ir.Document, _ declarations) ([]Violation, bool) { var vs []Violation optional := map[string]bool{} @@ -74,23 +79,74 @@ func checkNaming(doc *ir.Document, _ declarations) ([]Violation, bool) { if v.Type() != namingType { return true } - source, canon, hint := namingChannels(v) + source, canon, hint, aliases := namingChannels(v) if !optional[path] { vs = appendAbsentViolation(vs, source, canon, hint, path) } vs = appendNamingViolations(vs, source, canon, path) + vs = appendAliasViolations(vs, aliases, path) return false // Naming holds no references or nested Naming to descend into }) return vs, truncated } -// namingChannels reads the three name channels off one Naming. It reads fields +// namingChannels reads the four name channels off one Naming. It reads fields // rather than converting the value back to an ir.Naming because a value the walk -// reached through an unexported field cannot be (see ir.WalkValues). -func namingChannels(naming reflect.Value) (source, canon, hint string) { +// reached through an unexported field cannot be (see ir.WalkValues) — which is +// also why the aliases are copied out element by element rather than through +// Interface(). +func namingChannels(naming reflect.Value) (source, canon, hint string, aliases []string) { + list := naming.FieldByName("Aliases") + aliases = make([]string, list.Len()) + for i := range list.Len() { + aliases[i] = list.Index(i).String() + } return naming.FieldByName("Source").String(), naming.FieldByName("Canonical").String(), - naming.FieldByName("Hint").String() + naming.FieldByName("Hint").String(), + aliases +} + +// appendAliasViolations reports the ways an alias list can be one no producer +// meant to write. +// +// An alias is matched against a name some other schema wrote — an Avro alias is +// a full name such as "com.example.User" — so it is a verbatim channel like +// Source, not a neutral one like Canonical, and none of the neutrality rules +// above apply to it. Holding it to Canonical's grammar would be the lossy +// direction: neutralizing "com.example.User" to words discards the separators +// and the casing the match is made of, and invariant #2 forbids a lowering that +// throws that away. The IR is not deciding this spelling, it is recording one. +// +// What is left is decidable without a grammar. An empty alias matches nothing +// and a repeated one matches twice, so neither can be what a producer intended: +// both say a list was built wrong rather than that a name was spelled wrong. +// +// Paths name the offending entry the way the walk would have reached it, so a +// violation on a list of several says which one. +func appendAliasViolations(vs []Violation, aliases []string, path string) []Violation { + seen := make(map[string]bool, len(aliases)) + for i, alias := range aliases { + at := path + ".Aliases[" + strconv.Itoa(i) + "]" + if alias == "" { + vs = append(vs, Violation{ + Code: "ir/naming-alias-empty", + Message: "alias is empty, so it matches no name", + Path: at, + }) + continue + } + if seen[alias] { + vs = append(vs, Violation{ + Code: "ir/naming-alias-duplicate", + Message: "alias " + alias + " is listed more than once", + Path: at, + }) + continue + } + seen[alias] = true + } + return vs } // appendAbsentViolation reports an entity that no channel names. diff --git a/ir/irverify/naming_alias_test.go b/ir/irverify/naming_alias_test.go new file mode 100644 index 00000000..67f5d4e4 --- /dev/null +++ b/ir/irverify/naming_alias_test.go @@ -0,0 +1,93 @@ +package irverify_test + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/dexpace/morphic/ir" + "github.com/dexpace/morphic/ir/irverify" +) + +// aliasViolations returns the alias violations in a model named with aliases, +// filtered by code prefix so a test asserting none is not satisfied by some +// unrelated violation being absent. +func aliasViolations(t *testing.T, aliases ...string) []irverify.Violation { + t.Helper() + doc := modelNamed(ir.Naming{Source: "m", Canonical: "m", Aliases: aliases}) + var out []irverify.Violation + for _, v := range irverify.Verify(doc) { + if strings.HasPrefix(v.Code, "ir/naming-alias-") { + out = append(out, v) + } + } + return out +} + +// TestVerify_VerbatimAliasesAreClean pins the settlement this check rests on: an +// alias is matched against a name another schema wrote, so it is a verbatim +// channel like Source and none of Canonical's neutrality rules apply to it. +// "com.example.User" is what an Avro alias looks like, and every one of +// ir/naming-cased, ir/naming-not-words and ir/naming-unsegmented would fire on +// it if the alias were held to Canonical's grammar. +func TestVerify_VerbatimAliasesAreClean(t *testing.T) { + t.Parallel() + assert.Empty(t, aliasViolations(t, "com.example.User", "UserID", "user_id")) +} + +func TestVerify_EmptyAliasIsAViolation(t *testing.T) { + t.Parallel() + got := aliasViolations(t, "ok", "") + require.Len(t, got, 1, "one violation for the one empty entry") + assert.Equal(t, "ir/naming-alias-empty", got[0].Code) + assert.Equal(t, "doc.Types[t/x/M].Name.Aliases[1]", got[0].Path, + "the violation names the offending entry, not just the naming") +} + +func TestVerify_DuplicateAliasIsAViolation(t *testing.T) { + t.Parallel() + got := aliasViolations(t, "dup", "other", "dup") + require.Len(t, got, 1, "the repeat is reported, not the first occurrence") + assert.Equal(t, "ir/naming-alias-duplicate", got[0].Code) + assert.Equal(t, "doc.Types[t/x/M].Name.Aliases[2]", got[0].Path) + assert.Contains(t, got[0].Message, "dup") +} + +// TestVerify_RepeatedEmptyAliasReportsEachAsEmpty holds the interaction between +// the two rules: a second empty entry is a repeat as well as an empty one, and +// reporting it as a duplicate would name the wrong repair. +func TestVerify_RepeatedEmptyAliasReportsEachAsEmpty(t *testing.T) { + t.Parallel() + got := aliasViolations(t, "", "") + require.Len(t, got, 2) + for _, v := range got { + assert.Equal(t, "ir/naming-alias-empty", v.Code) + } +} + +// TestVerify_IssueReproducerIsReported drives the exact value from the issue — +// cased, punctuated, empty and duplicated together — and states which of the +// four the IR objects to and which it accepts by design. +func TestVerify_IssueReproducerIsReported(t *testing.T) { + t.Parallel() + got := aliasViolations(t, "UserID", "com.example.User", "", "dup", "dup") + require.Len(t, got, 2, "the cased and dotted entries are legitimate aliases") + + // Keyed rather than indexed: Verify sorts by (Code, Path), so asserting + // positionally would pin the sort order rather than what was reported. + byCode := map[string]string{} + for _, v := range got { + byCode[v.Code] = v.Path + } + assert.Equal(t, map[string]string{ + "ir/naming-alias-empty": "doc.Types[t/x/M].Name.Aliases[2]", + "ir/naming-alias-duplicate": "doc.Types[t/x/M].Name.Aliases[4]", + }, byCode) +} + +func TestVerify_NoAliasesIsClean(t *testing.T) { + t.Parallel() + assert.Empty(t, aliasViolations(t)) +} diff --git a/ir/naming.go b/ir/naming.go index 94a9fea3..328eb06a 100644 --- a/ir/naming.go +++ b/ir/naming.go @@ -28,6 +28,16 @@ type Naming struct { // Aliases are alternate names for schema-resolution matching (Avro // aliases). Versionless — rename history tied to version labels lives in // Availability.RenamedFrom. + // + // An alias is a verbatim channel like Source, not a neutral one like + // Canonical: it is matched against a name another schema wrote, so the + // casing and punctuation are the value. An Avro alias is a full name + // ("com.example.User"), and neutralizing it to words would lose the + // separators and the case the match depends on. So no neutrality rule + // applies to an entry, and irverify holds only what is decidable without + // one: every entry is non-empty and no entry repeats, since an empty alias + // matches nothing and a repeated one matches twice. Both mean a producer + // wrote a list it did not mean to write. Aliases []string `json:"aliases,omitempty"` } From d1c19a8fe5fe7349361b4fa2ae72074c45d6432d Mon Sep 17 00:00:00 2001 From: OmarAlJarrah Date: Tue, 11 Aug 2026 21:39:34 +0300 Subject: [PATCH 2/2] fix(irverify): widen the blank-alias rule and pin what the tests claim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blank rule was strings.TrimSpace, which reports false for the zero-width format characters: an alias of nothing but U+200B, U+FEFF, a soft hyphen or U+0000 verified clean while naming exactly as little as " " does. isBlankName replaces it with the widest test that still needs no format grammar — every rune a space, a control or a category Cf — and a case for a visible rune beside an invisible one holds the other side of that line, since judging that one would need the grammar. Three test claims did not hold. The blank/duplicate interaction case repeated two different blanks, so the duplicate rule could not fire on it under any implementation; it now repeats one string, and the natural rewrite it exists to forbid reddens it. A new case ties the hand-assembled violation path to what ir.WalkValues renders, which nothing did while checkNaming prunes at Naming. Two doc comments named the wrong witnesses: ir/naming-unsegmented fires on API2Key rather than the dotted name, and the base document is pinned by TestVerify_NoAliasesIsClean rather than TestVerify_NeutralCanonicalIsClean. Naming.Aliases justified the duplicate rule with "a repeated one matches twice" and then explained that it matches nothing extra at all. The second is right, and it is what makes the rule and the compiler-side deduplication one argument rather than two. --- docs/ir-design.md | 21 ++++-- ir/irverify/naming.go | 29 ++++++-- ir/irverify/naming_alias_test.go | 115 +++++++++++++++++++++++++++---- ir/naming.go | 14 ++-- 4 files changed, 148 insertions(+), 31 deletions(-) diff --git a/docs/ir-design.md b/docs/ir-design.md index 54b5dfec..56f320f8 100644 --- a/docs/ir-design.md +++ b/docs/ir-design.md @@ -244,12 +244,21 @@ what the match is made of rather than a spelling the IR gets to decide. Neutrali throw away precisely that, which is the lossy direction lossless-by-default rules out. `Source` is the internal precedent: it carries `UserID` today and no content rule touches it, because it records what the spec said rather than deciding a spelling. What is left is decidable without any -grammar, and `irverify` holds an alias to it: every entry names something — `""` and `" "` alike -match nothing (`ir/naming-alias-blank`) — and no entry repeats (`ir/naming-alias-duplicate`), -reported at the later entry so the path names the one to delete. Where a *source* repeats an alias -the compiler records it once with a diagnostic rather than carrying the repeat through: the second -entry admits no name the first does not, so the same set of names resolves to the entity either -way. +grammar, and `irverify` holds an alias to it: every entry names something, and no entry repeats. + +**Names something** is the widest emptiness test that needs no grammar: an entry whose every rune +is a space, a control character or a zero-width format character is invisible under all of them, so +it matches nothing anywhere (`ir/naming-alias-blank`). `""`, `" "`, `"\u200b"` and `"\ufeff"` are +alike here — trimming only what `unicode.IsSpace` reports would keep the last two, which name as +little as the first two do. An invisible rune sitting *beside* a visible one is a different +question and is not asked: whether `com.example.User` is a legal name is decidable only under +the grammar of the format it will be matched against, which the IR does not know. + +**No entry repeats** because a repeat admits no name the entry before it already did +(`ir/naming-alias-duplicate`, reported at the later entry so the path names the one to delete). A +producer that wrote one built the list wrong. That the repeat is inert is also why a *source* that +declares one is recorded once with a diagnostic rather than carried through: dropping it is not the +lossy direction, since the same set of names resolves to the entity either way. ### 3.3 Type references diff --git a/ir/irverify/naming.go b/ir/irverify/naming.go index b23d4942..3867fec8 100644 --- a/ir/irverify/naming.go +++ b/ir/irverify/naming.go @@ -116,10 +116,10 @@ func namingChannels(naming reflect.Value) (source, canon, hint string, aliases [ // neutrality rules above are — an alias is a spelling the IR records rather than // one it decides. // -// Blank rather than empty is the line, because it is the widest one decidable -// without a grammar: "" and " " name nothing under any format's rules, while -// deciding whether a space inside "com.example. User" is legal needs the grammar -// of the format the alias will be matched under, which the IR does not know. +// Blank rather than empty is the line, and isBlankName is how wide it goes. +// Deciding whether a space *inside* "com.example. User" is legal would need the +// grammar of the format the alias is matched under, which the IR does not know; +// deciding that an entry has nothing visible in it at all needs no grammar. // // A repeat is reported at its later entry, so the path names the one to delete // rather than the one to keep. A blank repeat is reported blank: the repair is @@ -136,7 +136,7 @@ func appendAliasViolations(vs []Violation, aliases []string, path string) []Viol for i, alias := range aliases { at := path + ".Aliases[" + strconv.Itoa(i) + "]" switch { - case strings.TrimSpace(alias) == "": + case isBlankName(alias): vs = append(vs, Violation{ Code: "ir/naming-alias-blank", Message: "alias is blank, so it matches no name", @@ -306,6 +306,25 @@ func isWordSequence(s string) bool { return true } +// isBlankName reports whether s holds no rune a name could be made of — the +// widest emptiness test there is that needs no format's grammar. A space, a +// control character and a zero-width format character are invisible under every +// grammar, so a string of nothing but those names nothing anywhere. +// +// strings.TrimSpace is not that test. unicode.IsSpace reports false for the +// zero-width joiners, the soft hyphen and the BOM — all category Cf — so an +// alias of nothing but U+200B or U+FEFF passes a check built on it while naming +// exactly as little as " " does, and so does one of nothing but U+0000, which is +// neither a space nor Cf. +func isBlankName(s string) bool { + for _, r := range s { + if !unicode.IsSpace(r) && !unicode.IsControl(r) && !unicode.Is(unicode.Cf, r) { + return false + } + } + return true +} + // isCased reports whether s still carries casing an emitter should own. The test // is lowercase-idempotence, not unicode.IsUpper: a compiler neutralizes names // with strings.ToLower, so a rune that has no lowercase form (double-struck ℤ, diff --git a/ir/irverify/naming_alias_test.go b/ir/irverify/naming_alias_test.go index b2ea31f9..c6a08a1a 100644 --- a/ir/irverify/naming_alias_test.go +++ b/ir/irverify/naming_alias_test.go @@ -1,7 +1,8 @@ package irverify_test import ( - "strconv" + "fmt" + "reflect" "testing" "github.com/stretchr/testify/assert" @@ -19,8 +20,8 @@ import ( // ir/naming-cased and ir/naming-not-words if some later change starts holding // aliases to Canonical's grammar; filtering to the alias codes would leave that // test unable to fail for the reason it exists. Nothing unrelated is in the way -// either — TestVerify_NeutralCanonicalIsClean asserts this same document, minus -// the aliases, verifies empty. +// either — TestVerify_NoAliasesIsClean asserts this exact document, with the +// alias list empty, verifies empty. func aliasViolations(t *testing.T, aliases ...string) []irverify.Violation { t.Helper() return irverify.Verify(modelNamed(ir.Naming{Source: "m", Canonical: "m", Aliases: aliases})) @@ -29,22 +30,40 @@ func aliasViolations(t *testing.T, aliases ...string) []irverify.Violation { // TestVerify_VerbatimAliasesAreClean pins the settlement this check rests on: an // alias is matched against a name another schema wrote, so it is a verbatim // channel like Source and none of Canonical's neutrality rules apply to it. -// "com.example.User" is what an Avro alias looks like, and every one of -// ir/naming-cased, ir/naming-not-words and ir/naming-unsegmented would fire on -// it if the alias were held to Canonical's grammar. +// "com.example.User" is what an Avro alias looks like, and between them these +// four aliases would draw all three of ir/naming-cased, ir/naming-not-words and +// ir/naming-unsegmented if an alias were held to Canonical's grammar — the +// dotted name the first two, "API2Key" the first and last. func TestVerify_VerbatimAliasesAreClean(t *testing.T) { t.Parallel() assert.Empty(t, aliasViolations(t, "com.example.User", "UserID", "user_id", "API2Key")) } // TestVerify_BlankAliasIsAViolation covers the whole of what "names nothing" -// means. Whitespace is as blank as "" — no format's grammar admits a name made -// of it — and testing emptiness alone would let " " through the one rule that +// means. An entry made only of runes no grammar can render a name from is as +// blank as "", and testing emptiness alone — or trimming only what +// unicode.IsSpace reports — would let most of these through the one rule that // exists to catch an entry matching nothing. func TestVerify_BlankAliasIsAViolation(t *testing.T) { t.Parallel() - for _, alias := range []string{"", " ", "\t", "\n", " \t "} { - t.Run(strconv.Quote(alias), func(t *testing.T) { + blanks := map[string]string{ + "empty": "", + "space": " ", + "tab": "\t", + "newline": "\n", + "mixed spaces": " \t ", + "no-break": "\u00a0", + "ideographic": "\u3000", + "zero width": "\u200b", + "byte order": "\ufeff", + "joiner": "\u200d", + "soft hyphen": "\u00ad", + "word joiner": "\u2060", + "nul": "\x00", + "invisible mix": "\u200b\t\ufeff", + } + for name, alias := range blanks { + t.Run(name, func(t *testing.T) { t.Parallel() got := aliasViolations(t, "ok", alias) require.Len(t, got, 1, "one violation for the one blank entry") @@ -55,6 +74,15 @@ func TestVerify_BlankAliasIsAViolation(t *testing.T) { } } +// TestVerify_InvisibleRuneBesideAVisibleOneIsNotBlank holds the other side of +// that line. Only an entry with nothing visible in it is blank; judging an +// invisible rune sitting beside a visible one needs the grammar of the format +// the alias is matched under, which the IR does not have. +func TestVerify_InvisibleRuneBesideAVisibleOneIsNotBlank(t *testing.T) { + t.Parallel() + assert.Empty(t, aliasViolations(t, "com.example.\u200bUser", " padded ")) +} + func TestVerify_DuplicateAliasIsAViolation(t *testing.T) { t.Parallel() got := aliasViolations(t, "dup", "other", "dup") @@ -67,12 +95,23 @@ func TestVerify_DuplicateAliasIsAViolation(t *testing.T) { // TestVerify_RepeatedBlankAliasReportsEachAsBlank holds the interaction between // the two rules: a second blank entry is a repeat as well as a blank one, and // reporting it as a duplicate would name the wrong repair. +// +// Each case repeats *the same* string, which is what makes the claim testable. +// With two different blanks the duplicate rule cannot fire whatever the +// implementation does, so the natural rewrite — report the repeat, then the +// blank, setting seen unconditionally — passes a two-different-blanks fixture +// while emitting exactly the wrong repair this test forbids. func TestVerify_RepeatedBlankAliasReportsEachAsBlank(t *testing.T) { t.Parallel() - got := aliasViolations(t, "", " ") - require.Len(t, got, 2) - for _, v := range got { - assert.Equal(t, "ir/naming-alias-blank", v.Code) + for name, alias := range map[string]string{"empty": "", "space": " ", "zero width": "\u200b"} { + t.Run(name, func(t *testing.T) { + t.Parallel() + got := aliasViolations(t, alias, alias) + require.Len(t, got, 2) + for _, v := range got { + assert.Equal(t, "ir/naming-alias-blank", v.Code) + } + }) } } @@ -100,3 +139,51 @@ func TestVerify_NoAliasesIsClean(t *testing.T) { t.Parallel() assert.Empty(t, aliasViolations(t)) } + +// TestVerify_AliasPathIsSpelledAsTheWalkWould ties the hand-assembled violation +// path to ir.WalkValues' own grammar. +// +// checkNaming prunes at ir.Naming — it holds no reference and no nested Naming +// to descend into — so the walk never renders these paths itself and the check +// spells them by hand. That leaves two statements of one grammar with nothing +// between them: were ir's slice-index rendering to change, every walk-produced +// path in every other check would move while these two codes alone kept the old +// spelling, and no test would say so. This is that seam, so it reddens here. +// +// Past the single digits too, which is where a hand-built path and a formatted +// one last agree. +func TestVerify_AliasPathIsSpelledAsTheWalkWould(t *testing.T) { + t.Parallel() + const size = 12 + aliases := make([]string, size) + for i := range aliases { + aliases[i] = fmt.Sprintf("alias.%d", i) // distinct, so no entry is a repeat + } + doc := modelNamed(ir.Naming{Source: "m", Canonical: "m", Aliases: aliases}) + + walked := map[string]string{} // alias → the path the walk renders for it + ir.WalkValues(doc, ir.DocumentPath, func(v reflect.Value, path string) bool { + if v.Kind() == reflect.String && v.String() != "" { + walked[v.String()] = path + } + return true + }) + for _, alias := range aliases { + require.Contains(t, walked, alias, "the walk reaches every entry when nothing prunes it") + } + + // Blank every entry so the check reports one violation per index, then hold + // each reported path to the one the walk rendered at that same index. + blank := make([]string, size) + got := aliasViolations(t, blank...) + require.Len(t, got, size) + paths := make([]string, len(got)) + for i, v := range got { + paths[i] = v.Path + } + want := make([]string, 0, size) + for _, alias := range aliases { + want = append(want, walked[alias]) + } + assert.ElementsMatch(t, want, paths) +} diff --git a/ir/naming.go b/ir/naming.go index a406bda1..41e827c3 100644 --- a/ir/naming.go +++ b/ir/naming.go @@ -38,13 +38,15 @@ type Naming struct { // ("com.example.User"), and neutralizing it to words would lose the // separators and the case the match depends on. So no neutrality rule // applies to an entry, and irverify holds only what is decidable without - // one: every entry names something and no entry repeats, since a blank - // alias matches nothing and a repeated one matches twice. + // one: every entry names something, since an entry with nothing visible in + // it matches nothing; and no entry repeats, since a repeat admits no name + // the entry before it already did, so a producer that wrote one built the + // list wrong. // - // A source that repeats an alias is recorded once, with a Diagnostic naming - // the repeat — not carried through as a repeat. That is not the lossy - // flattening invariant #2 forbids: the second entry admits no name the first - // does not, so the same set of names resolves to this entity either way. + // That a repeat is inert is also why a source that declares one is recorded + // once, with a Diagnostic naming it, rather than carried through: dropping + // it is not the lossy flattening invariant #2 forbids, because the same set + // of names resolves to this entity either way. Aliases []string `json:"aliases,omitempty"` }