diff --git a/internal/git/git.go b/internal/git/git.go index 3d69d5ac..0a8c1376 100644 --- a/internal/git/git.go +++ b/internal/git/git.go @@ -5,25 +5,27 @@ import ( "errors" "fmt" "os/exec" - "regexp" "strings" "time" + + "github.com/stablekernel/cascade/internal/taggrammar" ) -// versionTagRegex matches cascade's canonical version tags: vX.Y.Z, optionally -// with an -rc.N prerelease and a nested .hotfix.M segment. It is kept in lockstep -// with the parser in internal/version (see version.Parse). The git package cannot -// import internal/version directly because that package depends, transitively -// through internal/changelog, on this one; TestIsValidVersionTag_InSyncWithVersionParse -// asserts the two stay in agreement. -var versionTagRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+)(?:\.hotfix\.(\d+))?)?$`) - -// IsValidVersionTag reports whether tag is a well-formed cascade version tag. -// Tags that do not match (for example a vX.Y.Z-dryrun.N exercise tag, a foreign -// "nightly" or "latest" tag, or a typo) are invisible to version discovery so -// they can never be mistaken for the latest released or prereleased version. +// IsValidVersionTag reports whether tag is a well-formed cascade version tag +// under the default grammar. Tags that do not match (for example a +// vX.Y.Z-dryrun.N exercise tag, a foreign "nightly" or "latest" tag, or a typo) +// are invisible to version discovery so they can never be mistaken for the +// latest released or prereleased version. func IsValidVersionTag(tag string) bool { - return versionTagRegex.MatchString(tag) + return IsValidVersionTagSpec(taggrammar.Default(), tag) +} + +// IsValidVersionTagSpec reports whether tag is a well-formed version tag under +// spec. Version discovery and git both read this from the canonical grammar, so +// the predicate can never drift from the parser the way a hand-copied regex +// once could. +func IsValidVersionTagSpec(spec taggrammar.Spec, tag string) bool { + return spec.IsVersionTag(tag) } // GetChangedFiles returns the list of files changed between two commits @@ -490,7 +492,18 @@ func remoteRefAlreadyGone(out []byte) bool { // process working directory points elsewhere; an empty dir falls back to the // process working directory. func GetLatestReleaseTag(dir, prefix string) (string, string, error) { - cmd := exec.Command("git", "tag", "-l", prefix+"*", "--sort=-v:refname") + spec := taggrammar.Default() + spec.Prefix = prefix + return GetLatestReleaseTagSpec(dir, spec) +} + +// GetLatestReleaseTagSpec is GetLatestReleaseTag under a caller-supplied grammar. +// The prefix glob widens the candidate set to every tag that leads with the +// grammar's prefix; the release predicate then narrows it to a published +// release, so a custom pre-release token is classified correctly rather than +// slipping through a hard-wired "-rc." check. +func GetLatestReleaseTagSpec(dir string, spec taggrammar.Spec) (string, string, error) { + cmd := exec.Command("git", "tag", "-l", spec.Prefix+"*", "--sort=-v:refname") cmd.Dir = dir output, err := cmd.Output() if err != nil { @@ -502,15 +515,17 @@ func GetLatestReleaseTag(dir, prefix string) (string, string, error) { return "", "", nil } - // Find the first published release: a valid cascade version with no -rc - // suffix. Filtering through IsValidVersionTag keeps non-version tags (such as - // a vX.Y.Z-dryrun.N exercise tag, which also lacks an -rc suffix) from being - // mistaken for a release. + // Find the first published release: a tag that parses as a version under the + // grammar and carries no pre-release segment. Parsing through the grammar + // keeps non-version tags (such as a vX.Y.Z-dryrun.N exercise tag) out, and + // the no-pre-release check keeps pre-releases (rc, beta, or any custom token) + // from being mistaken for a release. for _, tag := range tags { - if !IsValidVersionTag(tag) { + parsed, ok := spec.Parse(tag) + if !ok { continue } - if !strings.Contains(tag, "-rc.") { + if parsed.PreRelease < 0 { // Get the SHA for this tag cmd = exec.Command("git", "rev-list", "-n", "1", tag) cmd.Dir = dir diff --git a/internal/git/git_test.go b/internal/git/git_test.go index cfe48204..82e08402 100644 --- a/internal/git/git_test.go +++ b/internal/git/git_test.go @@ -8,6 +8,8 @@ import ( "strings" "testing" "time" + + "github.com/stablekernel/cascade/internal/taggrammar" ) // newScratchRepo initializes a git repository in a temp directory, changes the @@ -705,3 +707,26 @@ func TestIsValidVersionTag(t *testing.T) { }) } } + +// TestGetLatestReleaseTag_CustomTokenNotMistakenForRelease proves the release +// classifier narrows on "parses with no pre-release" rather than a hard-wired +// "-rc." substring. Under a beta grammar, v1.2.3-beta.1 is a pre-release and +// must be skipped, so the lookup returns the real release v1.2.2. +func TestGetLatestReleaseTag_CustomTokenNotMistakenForRelease(t *testing.T) { + newScratchRepo(t) + commitFile(t, "a.txt", "one", "first commit") + + tagHead(t, "v1.2.2") // published release + tagHead(t, "v1.2.3-beta.1") // pre-release under the beta grammar + + spec := taggrammar.Default() + spec.PreReleaseToken = "beta" + + got, _, err := GetLatestReleaseTagSpec("", spec) + if err != nil { + t.Fatalf("GetLatestReleaseTagSpec() unexpected error: %v", err) + } + if got != "v1.2.2" { + t.Errorf("GetLatestReleaseTagSpec() = %q, want %q (beta pre-release must not count as a release)", got, "v1.2.2") + } +} diff --git a/internal/git/version_tag_sync_test.go b/internal/git/version_tag_sync_test.go index f9a5c7e5..e64847e5 100644 --- a/internal/git/version_tag_sync_test.go +++ b/internal/git/version_tag_sync_test.go @@ -1,18 +1,17 @@ -package git_test +package git import ( "testing" - "github.com/stablekernel/cascade/internal/git" - "github.com/stablekernel/cascade/internal/version" + "github.com/stablekernel/cascade/internal/taggrammar" ) -// TestIsValidVersionTag_InSyncWithVersionParse guards against the git package's -// local version predicate drifting from the canonical parser in internal/version. -// The git package cannot import internal/version directly (that would create an -// import cycle through internal/changelog), so this external test asserts the two -// agree across a representative corpus of tag strings. -func TestIsValidVersionTag_InSyncWithVersionParse(t *testing.T) { +// TestIsValidVersionTag_MatchesDefaultGrammar pins the git predicate to the +// canonical grammar. Both the git package and version discovery now read tag +// shape from internal/taggrammar, so the old cross-package drift class is gone; +// this test keeps a representative corpus honest against the default spec. +func TestIsValidVersionTag_MatchesDefaultGrammar(t *testing.T) { + spec := taggrammar.Default() corpus := []string{ "v1.2.3", "v0.5.1", @@ -41,10 +40,9 @@ func TestIsValidVersionTag_InSyncWithVersionParse(t *testing.T) { for _, tag := range corpus { tag := tag t.Run(tag, func(t *testing.T) { - _, err := version.Parse(tag) - wantValid := err == nil - if got := git.IsValidVersionTag(tag); got != wantValid { - t.Errorf("IsValidVersionTag(%q) = %v, but version.Parse success = %v (predicate drifted from canonical regex)", tag, got, wantValid) + want := spec.IsVersionTag(tag) + if got := IsValidVersionTag(tag); got != want { + t.Errorf("IsValidVersionTag(%q) = %v, want %v (predicate drifted from the canonical grammar)", tag, got, want) } }) } diff --git a/internal/version/version.go b/internal/version/version.go index ac16dccb..0ab09951 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -8,8 +8,26 @@ import ( "strings" "github.com/stablekernel/cascade/internal/changelog" + "github.com/stablekernel/cascade/internal/taggrammar" ) +// defaultSpec is cascade's historical tag grammar. The package-level Parse, +// ParseBase, and String helpers read from it so their behavior stays identical +// to the hand-written regexes they replaced, while the grammar itself lives in +// one place. +var defaultSpec = taggrammar.Default() + +// prefixPattern returns the regex fragment matching a tag prefix under spec: the +// literal prefix when StrictPrefix is set, otherwise any alphabetic run so +// historical and foreign-cased tags still parse. It mirrors the read side of the +// canonical grammar. +func prefixPattern(spec taggrammar.Spec) string { + if spec.StrictPrefix { + return regexp.QuoteMeta(spec.Prefix) + } + return "[a-zA-Z]*" +} + // Version represents a semantic version with optional pre-release suffix type Version struct { Major int @@ -18,6 +36,21 @@ type Version struct { PreRelease int // -1 means no pre-release suffix, >= 0 is the RC number Hotfix int // -1 means no hotfix segment, >= 0 is the hotfix number Prefix string // e.g., "v" or custom prefix + + // grammarSpec, when set, names the tag grammar this version renders under. + // It is nil for versions built under the default grammar, which keeps the + // zero value and every literal-constructed Version rendering identically to + // before. Only versions produced under a non-default grammar carry one. + grammarSpec *taggrammar.Spec +} + +// activeSpec returns the tag grammar this version renders under: its own when +// set, otherwise the default grammar. +func (v *Version) activeSpec() taggrammar.Spec { + if v.grammarSpec != nil { + return *v.grammarSpec + } + return defaultSpec } // BumpType represents the type of version bump @@ -30,27 +63,35 @@ const ( BumpMajor ) -// semverRegex matches versions like v1.2.3, v1.2.3-rc.4, or v1.2.3-rc.4.hotfix.5. -// The hotfix segment is only valid nested after an rc segment. -var semverRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-rc\.(\d+)(?:\.hotfix\.(\d+))?)?$`) - -// baseVersionRegex matches a semver core (vX.Y.Z) with any optional +// baseRegex matches a semver core (vX.Y.Z) under spec with any optional // pre-release suffix (for example -rc.4, -dryrun.13, or -beta.1). Only the // numeric core and prefix are captured; the suffix is intentionally ignored. -var baseVersionRegex = regexp.MustCompile(`^([a-zA-Z]*)(\d+)\.(\d+)\.(\d+)(?:-.+)?$`) - -// rcSuffixRegex captures the rc number from a version whose core is immediately -// followed by an -rc.N segment, tolerating any trailing exercise suffix (for -// example -rc.4.hotfix.5 or -rc.4.dryrun.1). It is anchored only at the start so -// a foreign suffix such as -beta.1 or -dryrun.4 simply yields no match. -var rcSuffixRegex = regexp.MustCompile(`^[a-zA-Z]*\d+\.\d+\.\d+-rc\.(\d+)`) - -// extractRC returns the rc number embedded in a version string, or -1 when the -// string has no -rc.N segment directly after its numeric core. It tolerates -// trailing suffixes the strict Parse rejects so a recorded dev version can still -// advance its rc counter instead of silently resetting to rc.0. -func extractRC(s string) int { - matches := rcSuffixRegex.FindStringSubmatch(s) +// The prefix fragment comes from the spec so a custom prefix widens or narrows +// the accepted set consistently with the strict parser. +func baseRegex(spec taggrammar.Spec) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf(`^(%s)(\d+)\.(\d+)\.(\d+)(?:-.+)?$`, prefixPattern(spec))) +} + +// preReleaseSuffixRegex captures the pre-release number from a version whose +// core is immediately followed by the spec's pre-release segment, tolerating any +// trailing exercise suffix (for example -rc.4.hotfix.5 or -rc.4.dryrun.1). It is +// anchored only at the start so a foreign suffix such as -beta.1 or -dryrun.4 +// simply yields no match. Token and separator both come from the spec. +func preReleaseSuffixRegex(spec taggrammar.Spec) *regexp.Regexp { + return regexp.MustCompile(fmt.Sprintf( + `^%s\d+\.\d+\.\d+-%s%s(\d+)`, + prefixPattern(spec), + regexp.QuoteMeta(spec.PreReleaseToken), + regexp.QuoteMeta(spec.PreReleaseSeparator), + )) +} + +// extractRCWithGrammar returns the pre-release number embedded in s under spec, +// or -1 when s has no pre-release segment directly after its numeric core. It +// tolerates trailing suffixes the strict Parse rejects so a recorded dev version +// can still advance its counter instead of silently resetting to zero. +func extractRCWithGrammar(spec taggrammar.Spec, s string) int { + matches := preReleaseSuffixRegex(spec).FindStringSubmatch(s) if matches == nil { return -1 } @@ -66,7 +107,14 @@ func extractRC(s string) int { // their next version solely from a base can use this so a stray suffixed value // recorded as the latest does not abort the whole calculation. func ParseBase(s string) (*Version, error) { - matches := baseVersionRegex.FindStringSubmatch(s) + return ParseBaseWithGrammar(defaultSpec, s) +} + +// ParseBaseWithGrammar parses the numeric core of s under spec, tolerating and +// discarding any pre-release suffix. See ParseBase for the full contract; this +// form lets callers supply a non-default grammar. +func ParseBaseWithGrammar(spec taggrammar.Spec, s string) (*Version, error) { + matches := baseRegex(spec).FindStringSubmatch(s) if matches == nil { return nil, fmt.Errorf("invalid version format: %s", s) } @@ -85,42 +133,49 @@ func ParseBase(s string) (*Version, error) { }, nil } -// Parse parses a version string into a Version struct +// Parse parses a version string into a Version struct under the default grammar. func Parse(s string) (*Version, error) { - matches := semverRegex.FindStringSubmatch(s) - if matches == nil { - return nil, fmt.Errorf("invalid version format: %s", s) - } - - major, _ := strconv.Atoi(matches[2]) - minor, _ := strconv.Atoi(matches[3]) - patch, _ := strconv.Atoi(matches[4]) - - preRelease := -1 - if matches[5] != "" { - preRelease, _ = strconv.Atoi(matches[5]) - } + return ParseWithGrammar(defaultSpec, s) +} - hotfix := -1 - if matches[6] != "" { - hotfix, _ = strconv.Atoi(matches[6]) +// ParseWithGrammar parses s into a Version under spec. The numeric fields come +// from the canonical grammar so the two never drift; the prefix is the literal +// run the tag leads with, which the grammar has already validated. +func ParseWithGrammar(spec taggrammar.Spec, s string) (*Version, error) { + p, ok := spec.Parse(s) + if !ok { + return nil, fmt.Errorf("invalid version format: %s", s) } return &Version{ - Major: major, - Minor: minor, - Patch: patch, - PreRelease: preRelease, - Hotfix: hotfix, - Prefix: matches[1], + Major: p.Major, + Minor: p.Minor, + Patch: p.Patch, + PreRelease: p.PreRelease, + Hotfix: p.Hotfix, + Prefix: leadingPrefix(s), }, nil } -// String returns the version as a string +// leadingPrefix returns the run of characters before the first digit in s, which +// for a validated version tag is exactly its prefix ("v", "release", or empty). +func leadingPrefix(s string) string { + i := strings.IndexFunc(s, func(r rune) bool { return r >= '0' && r <= '9' }) + if i < 0 { + return s + } + return s[:i] +} + +// String returns the version as a string. The prefix and numeric core come from +// the version's own fields; the pre-release token and separator come from its +// grammar, so a custom grammar renders its own shape while the default renders +// the historical "-rc." form. func (v *Version) String() string { + spec := v.activeSpec() base := fmt.Sprintf("%s%d.%d.%d", v.Prefix, v.Major, v.Minor, v.Patch) if v.PreRelease >= 0 { - rc := fmt.Sprintf("%s-rc.%d", base, v.PreRelease) + rc := fmt.Sprintf("%s-%s%s%d", base, spec.PreReleaseToken, spec.PreReleaseSeparator, v.PreRelease) if v.Hotfix >= 0 { return fmt.Sprintf("%s.hotfix.%d", rc, v.Hotfix) } @@ -219,15 +274,25 @@ func DetermineBumpType(commits []changelog.ConventionalCommit) BumpType { // Calculator handles version calculation for the release workflow type Calculator struct { - prefix string + spec taggrammar.Spec } -// NewCalculator creates a new version calculator +// NewCalculator creates a new version calculator for the default grammar with a +// custom prefix. An empty prefix defaults to "v". func NewCalculator(prefix string) *Calculator { if prefix == "" { prefix = "v" } - return &Calculator{prefix: prefix} + spec := taggrammar.Default() + spec.Prefix = prefix + return &Calculator{spec: spec} +} + +// NewCalculatorWithGrammar creates a version calculator that emits tags in the +// shape described by spec, so a caller with a non-default token, separator, or +// prefix gets a matching next version. +func NewCalculatorWithGrammar(spec taggrammar.Spec) *Calculator { + return &Calculator{spec: spec} } // CalculateNext determines the next version for the lowest environment @@ -248,7 +313,7 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com Patch: 0, PreRelease: -1, Hotfix: -1, - Prefix: c.prefix, + Prefix: c.spec.Prefix, } } else { // Only the numeric core of the next env's version feeds the @@ -257,7 +322,7 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com // must not abort the calculation, matching the discovery-side filtering // that keeps such exercise tags out of tag lookups. var err error - baseVersion, err = ParseBase(nextEnvVersion) + baseVersion, err = ParseBaseWithGrammar(c.spec, nextEnvVersion) if err != nil { return nil, fmt.Errorf("parsing next env version: %w", err) } @@ -273,7 +338,7 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com // Calculate the new version newVersion := baseVersion.BaseVersion().Bump(bumpType) - newVersion.Prefix = c.prefix + newVersion.Prefix = c.spec.Prefix // Ensure minimum version of v0.1.0 (v0.0.x is not valid for releases) if newVersion.Major == 0 && newVersion.Minor == 0 { @@ -291,22 +356,26 @@ func (c *Calculator) CalculateNext(currentDevVersion, nextEnvVersion string, com // example -beta.1, -dryrun.4, or -rc.4.dryrun.1) does not silently reset // the rc counter and collide with an already-published rc tag. This // mirrors the tolerant handling applied to nextEnvVersion above. - currentBase, err := ParseBase(currentDevVersion) + currentBase, err := ParseBaseWithGrammar(c.spec, currentDevVersion) switch { case err != nil: // Base itself is unparseable, so start fresh. newVersion.PreRelease = 0 case newVersion.Equal(currentBase): - // Same base version, so increment off the recorded rc. extractRC - // returns -1 when the dev version has no rc segment, yielding a - // fresh rc.0. - newVersion.PreRelease = extractRC(currentDevVersion) + 1 + // Same base version, so increment off the recorded rc. + // extractRCWithGrammar returns -1 when the dev version has no + // pre-release segment, yielding a fresh rc.0. + newVersion.PreRelease = extractRCWithGrammar(c.spec, currentDevVersion) + 1 default: // Different base version, so start at rc.0. newVersion.PreRelease = 0 } } + // Stamp the grammar so the result renders in the calculator's shape. + spec := c.spec + newVersion.grammarSpec = &spec + return newVersion, nil } diff --git a/internal/version/version_test.go b/internal/version/version_test.go index f3ef1b06..e2f9bbd7 100644 --- a/internal/version/version_test.go +++ b/internal/version/version_test.go @@ -4,6 +4,7 @@ import ( "testing" "github.com/stablekernel/cascade/internal/changelog" + "github.com/stablekernel/cascade/internal/taggrammar" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -726,6 +727,22 @@ func TestVersion_NextHotfix(t *testing.T) { }) } +// TestCalculator_CustomGrammarEmitsCustomShape proves the calculator emits the +// pre-release shape from its grammar rather than a hard-wired "-rc.". A beta +// token with an empty separator must yield "-beta0", not "-rc.0". +func TestCalculator_CustomGrammarEmitsCustomShape(t *testing.T) { + spec := taggrammar.Default() + spec.PreReleaseToken = "beta" + spec.PreReleaseSeparator = "" + + calc := NewCalculatorWithGrammar(spec) + got, err := calc.CalculateNext("", "", []changelog.ConventionalCommit{ + {Type: "feat", Description: "initial feature"}, + }) + require.NoError(t, err) + assert.Equal(t, "v0.1.0-beta0", got.String()) +} + func mustParse(t *testing.T, s string) *Version { t.Helper() v, err := Parse(s)