Files
semver-generator/cmd/utils/semver_test.go
T
lukaszraczylo e90191de4c feat(matching): add an opt-in regex keyword matching mode
Fuzzy matching compares each keyword with every word of the commit
message. A word matches if it contains the keyword's letters in order.
Thus "feat" matches "fix(delegation):" and "major" matches
"deps(majors):", which gives false minor and major versions.

The new top-level config key "matching" accepts "fuzzy" (the default)
or "regex". In regex mode every wording keyword is a Go regular
expression. The tool compiles all of them when it loads the config and
runs them against the full commit message, so "(?m)^" anchors a
pattern to the start of a line. An invalid value, an invalid pattern,
or any other config parse error in regex mode stops the tool with a
non-zero exit code. It never falls back to fuzzy matching.

Fuzzy mode does not change. The blacklist check moves into one helper
that both modes use. A replay of 15 repositories gives the same
versions as before. This change improves version precision for the
repositories that opt in.

Claude-Session: https://claude.ai/code/session_01Qq1gbqQek1Zjtofo4VDRdW
2026-09-25 19:35:48 +01:00

446 lines
12 KiB
Go

package utils
import (
"testing"
"time"
"github.com/lithammer/fuzzysearch/fuzzy"
"github.com/stretchr/testify/assert"
)
func TestCalculateSemver(t *testing.T) {
// Initialize logger for tests
InitLogger(false)
// Mock the fuzzy find function for testing
originalFuzzyFind := FuzzyFind
defer func() { FuzzyFind = originalFuzzyFind }()
FuzzyFind = func(needle string, haystack []string) []string {
// More sophisticated mock implementation for testing
for _, h := range haystack {
// Check for substring match to better simulate fuzzy search
if h == needle || (len(h) >= 3 && len(needle) >= 3 &&
(h[:3] == needle[:3] || h[len(h)-3:] == needle[len(needle)-3:])) {
return []string{h}
}
}
return nil
}
// Test data
now := time.Now()
// Common wording and blacklist for all tests
wording := Wording{
Patch: []string{"update", "fix", "initial"},
Minor: []string{"change", "feature", "improve"},
Major: []string{"breaking"},
Release: []string{"rc", "release-candidate"},
}
blacklist := []string{"skip-ci", "no-version"}
tests := []struct {
name string
commits []CommitDetails
tags []TagDetails
wording Wording
blacklist []string
initialSemver SemVer
respectExisting bool
strictMode bool
tagPrefixes []string
want SemVer
}{
{
name: "Standard mode with existing tags",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Update documentation",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{
{
Name: "2.0.0",
Hash: "commit1",
},
},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: true,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + one patch increment
Release: 1,
EnableReleaseCandidate: true,
},
},
{
name: "Strict mode with existing tags",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Update documentation",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{
{
Name: "2.0.0",
Hash: "commit1",
},
},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: true,
strictMode: true,
tagPrefixes: []string{},
want: SemVer{
Major: 2,
Minor: 0,
Patch: 1, // Initial tag 2.0.0 + patch from "update" keyword
Release: 1,
EnableReleaseCandidate: true,
},
},
{
name: "Standard mode without existing tags",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Update documentation",
Timestamp: now.Add(-2 * time.Hour),
},
{
Hash: "commit3",
Message: "Change API interface",
Timestamp: now.Add(-1 * time.Hour),
},
},
tags: []TagDetails{},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 1,
Patch: 1, // Minor increment resets patch to 1
},
},
{
name: "Strict mode without existing tags",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Update documentation",
Timestamp: now.Add(-2 * time.Hour),
},
{
Hash: "commit3",
Message: "Change API interface",
Timestamp: now.Add(-1 * time.Hour),
},
},
tags: []TagDetails{},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{Major: 1},
respectExisting: false,
strictMode: true,
tagPrefixes: []string{},
want: SemVer{
Major: 1,
Minor: 1,
Patch: 1, // Minor increment resets patch to 1
},
},
{
name: "With blacklisted commits",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Update documentation skip-ci",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 0,
Patch: 3, // Default patch increment + patch from initial
},
},
{
name: "With release candidate",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "Initial commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "Add release-candidate",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: false,
strictMode: false,
tagPrefixes: []string{},
want: SemVer{
Major: 0,
Minor: 0,
Patch: 1,
Release: 1,
EnableReleaseCandidate: true,
},
},
{
name: "With prefixed tags should not be RC",
commits: []CommitDetails{
{
Hash: "commit1",
Message: "tagged commit",
Timestamp: now.Add(-3 * time.Hour),
},
{
Hash: "commit2",
Message: "another commit",
Timestamp: now.Add(-2 * time.Hour),
},
},
tags: []TagDetails{
{
Name: "app-0.0.16",
Hash: "commit1",
},
},
wording: wording,
blacklist: blacklist,
initialSemver: SemVer{},
respectExisting: true,
strictMode: true, // Use strict mode for predictable results
tagPrefixes: []string{"app-", "infra-"},
want: SemVer{
Major: 0,
Minor: 0,
Patch: 16, // From tag, no additional increments in strict mode
EnableReleaseCandidate: false,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := CalculateSemver(
tt.commits,
tt.tags,
tt.wording,
FuzzyMatcher{},
tt.blacklist,
tt.initialSemver,
tt.respectExisting,
tt.strictMode,
tt.tagPrefixes,
)
assert.Equal(t, tt.want.Major, got.Major, "Major version mismatch")
assert.Equal(t, tt.want.Minor, got.Minor, "Minor version mismatch")
assert.Equal(t, tt.want.Patch, got.Patch, "Patch version mismatch")
assert.Equal(t, tt.want.Release, got.Release, "Release version mismatch")
assert.Equal(t, tt.want.EnableReleaseCandidate, got.EnableReleaseCandidate, "EnableReleaseCandidate mismatch")
})
}
}
// TestCalculateSemverRegexModeEndToEnd exercises the whole config->matcher->
// CalculateSemver path in regex mode, with anchored conventional-commit
// patterns and a "Semver-Major:" trailer.
func TestCalculateSemverRegexModeEndToEnd(t *testing.T) {
InitLogger(false)
wording := Wording{
Patch: []string{`^fix:`},
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
Major: []string{`(?mi)^semver-major:`},
Release: []string{},
}
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
assert.NoError(t, err)
// c4 ("fix(delegation): ...") is placed AFTER the major commit (c5) on
// purpose: the anchored minor pattern `(?m)^feat(\([^)]*\))?!?:` must not
// match a message that starts with "fix", so c4 contributes no version
// bump at all under correct regex matching. If a future regression made
// RegexMatcher match per-word instead of against the full raw message
// (the false positive fuzzy mode has, see matcher_test.go's
// "full-message anchoring" cases), c4 would wrongly bump Minor. With c4
// last, nothing runs after it to reset that spurious bump back to 0, so
// the final assertions below would catch it. (With c4 before c5, as it
// used to be, the major commit's Minor=0 reset would mask the same bug.)
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "chore: init repo", Timestamp: now.Add(-5 * time.Hour)},
{Hash: "c2", Message: "fix: correct off-by-one", Timestamp: now.Add(-4 * time.Hour)},
{Hash: "c3", Message: "feat: add widget\n\nsome body text", Timestamp: now.Add(-3 * time.Hour)},
{Hash: "c5", Message: "docs: update README\n\nSemver-Major: breaking storage format", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c4", Message: "fix(delegation): route around dead worker", Timestamp: now.Add(-1 * time.Hour)},
}
got := CalculateSemver(
commits,
nil,
wording,
matcher,
nil,
SemVer{},
false,
true, // strict mode: only wording matches move the version
nil,
)
assert.Equal(t, 1, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
assert.Equal(t, 0, got.Release, "Release version mismatch")
assert.False(t, got.EnableReleaseCandidate)
}
// TestCalculateSemverRegexModeReleaseCandidateEndToEnd exercises
// wording.Release in regex mode end to end: a commit matching the release
// pattern must bump the release-candidate counter, exactly like fuzzy mode
// does for wording.Release matches.
func TestCalculateSemverRegexModeReleaseCandidateEndToEnd(t *testing.T) {
InitLogger(false)
wording := Wording{
Patch: []string{`^fix:`},
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
Major: []string{`(?mi)^semver-major:`},
Release: []string{`(?m)^release-candidate:`},
}
matcher, err := NewKeywordMatcher(MatchingRegex, wording)
assert.NoError(t, err)
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "fix: correct off-by-one", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c2", Message: "release-candidate: cut rc for QA", Timestamp: now.Add(-1 * time.Hour)},
}
got := CalculateSemver(
commits,
nil,
wording,
matcher,
nil,
SemVer{},
false,
true, // strict mode: only wording matches move the version
nil,
)
assert.Equal(t, 0, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 1, got.Patch, "Patch version mismatch")
assert.Equal(t, 1, got.Release, "Release version mismatch")
assert.True(t, got.EnableReleaseCandidate)
}
// TestCalculateSemverFuzzyModeUnchanged runs CalculateSemver with the real
// fuzzy.FindNormalizedFold implementation (the same one main.go wires up) to
// confirm fuzzy mode's results are unchanged by the matcher refactor.
func TestCalculateSemverFuzzyModeUnchanged(t *testing.T) {
InitLogger(false)
originalFuzzyFind := FuzzyFind
defer func() { FuzzyFind = originalFuzzyFind }()
FuzzyFind = fuzzy.FindNormalizedFold
wording := Wording{
Patch: []string{"fix"},
Minor: []string{"feat"},
Major: []string{"breaking"},
}
blacklist := []string{"skip-ci"}
now := time.Now()
commits := []CommitDetails{
{Hash: "c1", Message: "chore: init", Timestamp: now.Add(-6 * time.Hour)},
{Hash: "c2", Message: "fix: correct bug", Timestamp: now.Add(-5 * time.Hour)},
{Hash: "c3", Message: "feat: add widget", Timestamp: now.Add(-4 * time.Hour)},
{Hash: "c4", Message: "docs: update readme", Timestamp: now.Add(-3 * time.Hour)},
{Hash: "c5", Message: "breaking: change api contract", Timestamp: now.Add(-2 * time.Hour)},
{Hash: "c6", Message: "fix: cleanup skip-ci", Timestamp: now.Add(-1 * time.Hour)},
}
matcher, err := NewKeywordMatcher(MatchingFuzzy, wording)
assert.NoError(t, err)
got := CalculateSemver(
commits,
nil,
wording,
matcher,
blacklist,
SemVer{},
false,
false, // non-strict: default patch increment applies too
nil,
)
assert.Equal(t, 1, got.Major, "Major version mismatch")
assert.Equal(t, 0, got.Minor, "Minor version mismatch")
assert.Equal(t, 2, got.Patch, "Patch version mismatch")
assert.Equal(t, 0, got.Release, "Release version mismatch")
assert.False(t, got.EnableReleaseCandidate)
}