From 17a5b5ec10ccfc27ed33ae6e88a6735d425cd2d5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 21:52:21 -0400 Subject: [PATCH 1/2] feat(taggrammar): add canonical dependency-free tag grammar spec Signed-off-by: Joshua Temple --- internal/taggrammar/grammar.go | 72 +++++++++++++++++++++++++++++ internal/taggrammar/grammar_test.go | 30 ++++++++++++ 2 files changed, 102 insertions(+) create mode 100644 internal/taggrammar/grammar.go create mode 100644 internal/taggrammar/grammar_test.go diff --git a/internal/taggrammar/grammar.go b/internal/taggrammar/grammar.go new file mode 100644 index 00000000..9c483d27 --- /dev/null +++ b/internal/taggrammar/grammar.go @@ -0,0 +1,72 @@ +// Package taggrammar is the single source of truth for the shape of cascade's +// own release tags. It is intentionally dependency-free (stdlib only) so that +// both version discovery and git tagging can share one grammar without pulling +// in cascade internals. +package taggrammar + +import ( + "fmt" + "regexp" +) + +// Spec describes the shape of a release tag. The zero value is not usable; +// construct a Spec with Default and override individual fields as needed. +// Every field defaults to cascade's historical grammar. +type Spec struct { + // Prefix is the literal string that leads a tag, historically "v". + Prefix string + // PreReleaseToken names a pre-release, historically "rc". + PreReleaseToken string + // PreReleaseSeparator sits between the token and its number, + // historically ".". + PreReleaseSeparator string + // DryRunToken names a rehearsal tag, historically "dryrun". Rehearsal + // tags are deliberately not version-parseable so they stay invisible to + // version discovery. + DryRunToken string + // StrictPrefix, when true, reads the prefix literally. When false the + // read side accepts any alphabetic prefix so historical and foreign-cased + // tags still parse. + StrictPrefix bool +} + +// Default returns the historical cascade grammar: v-prefixed semantic versions +// with "rc." pre-releases and "dryrun" rehearsal tags. +func Default() Spec { + return Spec{ + Prefix: "v", + PreReleaseToken: "rc", + PreReleaseSeparator: ".", + DryRunToken: "dryrun", + } +} + +// prefixPattern returns the regex fragment used to match the tag prefix. With +// StrictPrefix the configured prefix is matched literally; otherwise any +// alphabetic run is accepted so the read side stays permissive. +func (s Spec) prefixPattern() string { + if s.StrictPrefix { + return regexp.QuoteMeta(s.Prefix) + } + return "[a-zA-Z]*" +} + +// versionRegex compiles the anchored pattern that matches a version-parseable +// tag: prefix, numeric core, an optional pre-release using the configured token +// and separator, and an optional nested ".hotfix.". +func (s Spec) versionRegex() *regexp.Regexp { + pattern := fmt.Sprintf( + `^(%s)(\d+)\.(\d+)\.(\d+)(?:-%s%s(\d+)(?:\.hotfix\.(\d+))?)?$`, + s.prefixPattern(), + regexp.QuoteMeta(s.PreReleaseToken), + regexp.QuoteMeta(s.PreReleaseSeparator), + ) + return regexp.MustCompile(pattern) +} + +// IsVersionTag reports whether tag is a version-parseable tag under this Spec. +// Rehearsal (dryrun) tags and foreign tags return false so they stay invisible +// to version discovery. +func (s Spec) IsVersionTag(tag string) bool { + return s.versionRegex().MatchString(tag) +} diff --git a/internal/taggrammar/grammar_test.go b/internal/taggrammar/grammar_test.go new file mode 100644 index 00000000..b68c38cd --- /dev/null +++ b/internal/taggrammar/grammar_test.go @@ -0,0 +1,30 @@ +package taggrammar + +import "testing" + +func TestDefault_ReproducesHistoricalGrammar(t *testing.T) { + s := Default() + if s.Prefix != "v" || s.PreReleaseToken != "rc" || + s.PreReleaseSeparator != "." || s.DryRunToken != "dryrun" { + t.Fatalf("Default() drifted from historical grammar: %+v", s) + } +} + +func TestParse_DefaultAcceptsCanonicalTags(t *testing.T) { + s := Default() + cases := map[string]bool{ + "v1.2.3": true, + "v1.2.3-rc.4": true, + "v1.2.3-rc.4.hotfix.5": true, + "1.2.3": true, // permissive read prefix + "v1.2.3-dryrun.4": false, // dryrun is not a version-parseable tag + "v1.2.3-rc.x": false, + "v1.2.3-hotfix.1": false, // hotfix only nests under rc + "nightly": false, + } + for tag, want := range cases { + if got := s.IsVersionTag(tag); got != want { + t.Errorf("IsVersionTag(%q) = %v, want %v", tag, got, want) + } + } +} From 8923e4ef5edd12c09661e2dc295ad9210a4182a5 Mon Sep 17 00:00:00 2001 From: Joshua Temple Date: Mon, 6 Jul 2026 21:53:10 -0400 Subject: [PATCH 2/2] feat(taggrammar): parse and format tags from a spec Signed-off-by: Joshua Temple --- internal/taggrammar/grammar.go | 58 ++++++++++++++++++++++++++++ internal/taggrammar/grammar_test.go | 60 ++++++++++++++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/internal/taggrammar/grammar.go b/internal/taggrammar/grammar.go index 9c483d27..7d674fc4 100644 --- a/internal/taggrammar/grammar.go +++ b/internal/taggrammar/grammar.go @@ -7,6 +7,7 @@ package taggrammar import ( "fmt" "regexp" + "strconv" ) // Spec describes the shape of a release tag. The zero value is not usable; @@ -70,3 +71,60 @@ func (s Spec) versionRegex() *regexp.Regexp { func (s Spec) IsVersionTag(tag string) bool { return s.versionRegex().MatchString(tag) } + +// Parsed holds the numeric fields of a version tag. PreRelease and Hotfix use +// -1 to mean absent, matching the convention used elsewhere in cascade. +type Parsed struct { + Major int + Minor int + Patch int + PreRelease int + Hotfix int +} + +// Parse extracts the numeric fields of tag under this Spec. It returns ok=false +// when tag is not a version tag. An absent pre-release or hotfix is reported as +// -1. +func (s Spec) Parse(tag string) (Parsed, bool) { + m := s.versionRegex().FindStringSubmatch(tag) + if m == nil { + return Parsed{}, false + } + // Submatch layout: [0] whole, [1] prefix, [2] major, [3] minor, + // [4] patch, [5] pre-release, [6] hotfix. + p := Parsed{ + Major: atoi(m[2]), + Minor: atoi(m[3]), + Patch: atoi(m[4]), + PreRelease: -1, + Hotfix: -1, + } + if m[5] != "" { + p.PreRelease = atoi(m[5]) + } + if m[6] != "" { + p.Hotfix = atoi(m[6]) + } + return p, true +} + +// Format renders p under this Spec. It always emits the prefix and numeric +// core, appends "-" when PreRelease is present, and +// appends ".hotfix." when Hotfix is present. +func (s Spec) Format(p Parsed) string { + out := fmt.Sprintf("%s%d.%d.%d", s.Prefix, p.Major, p.Minor, p.Patch) + if p.PreRelease >= 0 { + out += fmt.Sprintf("-%s%s%d", s.PreReleaseToken, s.PreReleaseSeparator, p.PreRelease) + } + if p.Hotfix >= 0 { + out += fmt.Sprintf(".hotfix.%d", p.Hotfix) + } + return out +} + +// atoi converts a submatch known to be all digits. The grammar guarantees the +// input, so any error is discarded and yields 0. +func atoi(s string) int { + n, _ := strconv.Atoi(s) + return n +} diff --git a/internal/taggrammar/grammar_test.go b/internal/taggrammar/grammar_test.go index b68c38cd..17ebb4af 100644 --- a/internal/taggrammar/grammar_test.go +++ b/internal/taggrammar/grammar_test.go @@ -1,6 +1,9 @@ package taggrammar -import "testing" +import ( + "reflect" + "testing" +) func TestDefault_ReproducesHistoricalGrammar(t *testing.T) { s := Default() @@ -28,3 +31,58 @@ func TestParse_DefaultAcceptsCanonicalTags(t *testing.T) { } } } + +func TestParse_DefaultExtractsFields(t *testing.T) { + s := Default() + cases := map[string]Parsed{ + "v1.2.3": {Major: 1, Minor: 2, Patch: 3, PreRelease: -1, Hotfix: -1}, + "v1.2.3-rc.4": {Major: 1, Minor: 2, Patch: 3, PreRelease: 4, Hotfix: -1}, + "v1.2.3-rc.4.hotfix.5": {Major: 1, Minor: 2, Patch: 3, PreRelease: 4, Hotfix: 5}, + } + for tag, want := range cases { + got, ok := s.Parse(tag) + if !ok { + t.Errorf("Parse(%q) ok = false, want true", tag) + continue + } + if !reflect.DeepEqual(got, want) { + t.Errorf("Parse(%q) = %+v, want %+v", tag, got, want) + } + } + if _, ok := s.Parse("nightly"); ok { + t.Errorf("Parse(%q) ok = true, want false", "nightly") + } +} + +func TestFormat_DefaultRoundTripsHistoricalStrings(t *testing.T) { + s := Default() + for _, tag := range []string{"v1.2.3", "v1.2.3-rc.4", "v1.2.3-rc.4.hotfix.5"} { + p, ok := s.Parse(tag) + if !ok { + t.Fatalf("Parse(%q) ok = false, want true", tag) + } + if got := s.Format(p); got != tag { + t.Errorf("Format(Parse(%q)) = %q, want %q", tag, got, tag) + } + } +} + +func TestParseFormat_NonDefaultSpec(t *testing.T) { + s := Default() + s.Prefix = "" + s.PreReleaseToken = "beta" + s.PreReleaseSeparator = "" + + const tag = "1.2.3-beta4" + p, ok := s.Parse(tag) + if !ok { + t.Fatalf("Parse(%q) ok = false, want true", tag) + } + want := Parsed{Major: 1, Minor: 2, Patch: 3, PreRelease: 4, Hotfix: -1} + if !reflect.DeepEqual(p, want) { + t.Errorf("Parse(%q) = %+v, want %+v", tag, p, want) + } + if got := s.Format(p); got != tag { + t.Errorf("Format(Parse(%q)) = %q, want %q", tag, got, tag) + } +}