mirror of
https://github.com/lukaszraczylo/semver-generator.git
synced 2026-10-12 00:45:45 +00:00
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
400 lines
10 KiB
Go
400 lines
10 KiB
Go
package utils
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"testing"
|
|
|
|
"github.com/lithammer/fuzzysearch/fuzzy"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
)
|
|
|
|
func TestApplyForcedVersioning(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
force Force
|
|
semver SemVer
|
|
want SemVer
|
|
}{
|
|
{
|
|
name: "No forced versioning",
|
|
force: Force{
|
|
Major: 0,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
},
|
|
semver: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
want: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
},
|
|
{
|
|
name: "Force major version",
|
|
force: Force{
|
|
Major: 5,
|
|
Minor: 0,
|
|
Patch: 0,
|
|
},
|
|
semver: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
want: SemVer{
|
|
Major: 5,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
},
|
|
{
|
|
name: "Force minor version",
|
|
force: Force{
|
|
Major: 0,
|
|
Minor: 7,
|
|
Patch: 0,
|
|
},
|
|
semver: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
want: SemVer{
|
|
Major: 1,
|
|
Minor: 7,
|
|
Patch: 3,
|
|
},
|
|
},
|
|
{
|
|
name: "Force patch version",
|
|
force: Force{
|
|
Major: 0,
|
|
Minor: 0,
|
|
Patch: 9,
|
|
},
|
|
semver: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
want: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 9,
|
|
},
|
|
},
|
|
{
|
|
name: "Force all versions",
|
|
force: Force{
|
|
Major: 5,
|
|
Minor: 7,
|
|
Patch: 9,
|
|
},
|
|
semver: SemVer{
|
|
Major: 1,
|
|
Minor: 2,
|
|
Patch: 3,
|
|
},
|
|
want: SemVer{
|
|
Major: 5,
|
|
Minor: 7,
|
|
Patch: 9,
|
|
},
|
|
},
|
|
}
|
|
|
|
// Initialize logger for tests
|
|
InitLogger(false)
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
semver := tt.semver
|
|
ApplyForcedVersioning(tt.force, &semver)
|
|
assert.Equal(t, tt.want.Major, semver.Major, "Major version mismatch")
|
|
assert.Equal(t, tt.want.Minor, semver.Minor, "Minor version mismatch")
|
|
assert.Equal(t, tt.want.Patch, semver.Patch, "Patch version mismatch")
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestReadConfig(t *testing.T) {
|
|
// Create a temporary config file for testing
|
|
configContent := `
|
|
version: 1
|
|
force:
|
|
major: 2
|
|
minor: 3
|
|
patch: 4
|
|
commit: abcdef1234567890
|
|
existing: true
|
|
strict: false
|
|
blacklist:
|
|
- "Merge branch"
|
|
- "Merge pull request"
|
|
wording:
|
|
patch:
|
|
- update
|
|
- fix
|
|
minor:
|
|
- change
|
|
- feature
|
|
major:
|
|
- breaking
|
|
release:
|
|
- release-candidate
|
|
`
|
|
tempFile, err := os.CreateTemp("", "semver-config-*.yaml")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp file: %v", err)
|
|
}
|
|
defer os.Remove(tempFile.Name())
|
|
|
|
if _, err := tempFile.Write([]byte(configContent)); err != nil {
|
|
t.Fatalf("Failed to write to temp file: %v", err)
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
t.Fatalf("Failed to close temp file: %v", err)
|
|
}
|
|
|
|
// Initialize logger for tests
|
|
InitLogger(false)
|
|
|
|
// Test reading the config
|
|
config, err := ReadConfig(tempFile.Name())
|
|
assert.NoError(t, err)
|
|
assert.NotNil(t, config)
|
|
|
|
// Verify force settings
|
|
assert.Equal(t, 2, config.Force.Major)
|
|
assert.Equal(t, 3, config.Force.Minor)
|
|
assert.Equal(t, 4, config.Force.Patch)
|
|
assert.Equal(t, "abcdef1234567890", config.Force.Commit)
|
|
assert.True(t, config.Force.Existing)
|
|
assert.False(t, config.Force.Strict)
|
|
|
|
// Verify blacklist
|
|
assert.Len(t, config.Blacklist, 2)
|
|
assert.Contains(t, config.Blacklist, "Merge branch")
|
|
assert.Contains(t, config.Blacklist, "Merge pull request")
|
|
|
|
// Verify wording
|
|
assert.Len(t, config.Wording.Patch, 2)
|
|
assert.Contains(t, config.Wording.Patch, "update")
|
|
assert.Contains(t, config.Wording.Patch, "fix")
|
|
|
|
assert.Len(t, config.Wording.Minor, 2)
|
|
assert.Contains(t, config.Wording.Minor, "change")
|
|
assert.Contains(t, config.Wording.Minor, "feature")
|
|
|
|
assert.Len(t, config.Wording.Major, 1)
|
|
assert.Contains(t, config.Wording.Major, "breaking")
|
|
|
|
assert.Len(t, config.Wording.Release, 1)
|
|
assert.Contains(t, config.Wording.Release, "release-candidate")
|
|
|
|
// Test reading a non-existent config
|
|
_, err = ReadConfig("non-existent-file.yaml")
|
|
assert.Error(t, err)
|
|
}
|
|
|
|
func writeTempConfig(t *testing.T, content string) string {
|
|
t.Helper()
|
|
|
|
tempFile, err := os.CreateTemp("", "semver-config-matching-*.yaml")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create temp file: %v", err)
|
|
}
|
|
if _, err := tempFile.Write([]byte(content)); err != nil {
|
|
t.Fatalf("Failed to write to temp file: %v", err)
|
|
}
|
|
if err := tempFile.Close(); err != nil {
|
|
t.Fatalf("Failed to close temp file: %v", err)
|
|
}
|
|
t.Cleanup(func() {
|
|
if err := os.Remove(tempFile.Name()); err != nil && !os.IsNotExist(err) {
|
|
t.Logf("failed to remove temp config %s: %v", tempFile.Name(), err)
|
|
}
|
|
})
|
|
return tempFile.Name()
|
|
}
|
|
|
|
func TestReadConfigMatching(t *testing.T) {
|
|
InitLogger(false)
|
|
|
|
t.Run("absent matching key defaults to fuzzy", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
wording:
|
|
patch:
|
|
- feat
|
|
`)
|
|
config, err := ReadConfig(path)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, MatchingFuzzy, config.Matching)
|
|
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
|
|
|
|
// Behavioural check, not just the type: absent/fuzzy mode means
|
|
// "fix(delegation): x" fuzzy-matches keyword "feat", using the real
|
|
// fuzzy library (the same one main.go wires up), not a stand-in.
|
|
originalFuzzyFind := FuzzyFind
|
|
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
|
FuzzyFind = fuzzy.FindNormalizedFold
|
|
assert.True(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Patch, nil))
|
|
})
|
|
|
|
t.Run("explicit fuzzy", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: fuzzy
|
|
wording:
|
|
patch:
|
|
- update
|
|
`)
|
|
config, err := ReadConfig(path)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, MatchingFuzzy, config.Matching)
|
|
assert.IsType(t, FuzzyMatcher{}, config.Matcher)
|
|
|
|
// Behavioural check, not just the type: same as the absent-key
|
|
// subtest above, using the real fuzzy library.
|
|
originalFuzzyFind := FuzzyFind
|
|
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
|
FuzzyFind = fuzzy.FindNormalizedFold
|
|
assert.True(t, config.Matcher.Matches("fix(delegation): x", []string{"feat"}, nil))
|
|
})
|
|
|
|
t.Run("explicit regex compiles keywords", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: regex
|
|
wording:
|
|
patch:
|
|
- "^fix:"
|
|
minor:
|
|
- "(?m)^feat(\\([^)]*\\))?!?:"
|
|
major:
|
|
- "(?mi)^semver-major:"
|
|
`)
|
|
config, err := ReadConfig(path)
|
|
assert.NoError(t, err)
|
|
assert.Equal(t, MatchingRegex, config.Matching)
|
|
assert.IsType(t, RegexMatcher{}, config.Matcher)
|
|
// Behavioural check: "fix(delegation): x" matches fuzzy keyword "feat"
|
|
// (see the absent/fuzzy subtest below) but must NOT match this
|
|
// anchored regex pattern.
|
|
assert.False(t, config.Matcher.Matches("fix(delegation): x", config.Wording.Minor, nil))
|
|
})
|
|
|
|
t.Run("unknown matching value errors naming value and allowed values", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: substring
|
|
wording:
|
|
patch:
|
|
- update
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
|
assert.Contains(t, err.Error(), "substring")
|
|
assert.Contains(t, err.Error(), MatchingFuzzy)
|
|
assert.Contains(t, err.Error(), MatchingRegex)
|
|
})
|
|
|
|
// Fix 1: an invalid "matching" value must be rejected immediately, before
|
|
// wording/force/blacklist/tag_prefixes are ever parsed. Previously a
|
|
// mistyped value like "substring" was accepted as-is by the case string
|
|
// branch, so a malformed wording section below it returned an unwrapped
|
|
// parse error instead of ErrInvalidMatchingConfig, and main() silently
|
|
// fell back to fuzzy mode instead of failing loudly.
|
|
t.Run("invalid matching value combined with malformed wording section still errors as ErrInvalidMatchingConfig", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: substring
|
|
wording: "not-a-map"
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
|
assert.Contains(t, err.Error(), "substring")
|
|
})
|
|
|
|
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: regex
|
|
wording:
|
|
major:
|
|
- "[unterminated("
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
|
assert.Contains(t, err.Error(), "major")
|
|
assert.Contains(t, err.Error(), "[unterminated(")
|
|
})
|
|
|
|
// Fix 2: no silent fuzzy fallback in regex mode. A malformed "wording"
|
|
// section (a scalar instead of a map) fails viper.UnmarshalKey, not
|
|
// NewKeywordMatcher. In regex mode that must still be a fatal,
|
|
// ErrInvalidMatchingConfig-wrapped error — main() exits non-zero on it
|
|
// (see cmd/main.go's errors.Is(err, utils.ErrInvalidMatchingConfig)
|
|
// check) instead of silently logging and falling back to fuzzy matching.
|
|
t.Run("regex mode + malformed wording section is a fatal error, not a fuzzy fallback", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching: regex
|
|
wording: "not-a-map"
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig, "main() only treats ErrInvalidMatchingConfig as fatal; anything else falls back to fuzzy and continues")
|
|
assert.Contains(t, err.Error(), "wording")
|
|
})
|
|
|
|
// Contrast case: the exact same malformed "wording" section in fuzzy mode
|
|
// (the default) must behave exactly as it did before regex mode existed —
|
|
// an error is returned, but it is NOT wrapped in ErrInvalidMatchingConfig,
|
|
// so main() logs it and continues with defaults rather than exiting.
|
|
t.Run("fuzzy mode + malformed wording section stays non-fatal, same as today", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
wording: "not-a-map"
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
assert.Error(t, err)
|
|
assert.False(t, errors.Is(err, ErrInvalidMatchingConfig), "fuzzy mode must not fail loudly for non-matching config errors")
|
|
})
|
|
|
|
// Fix 4: a non-scalar "matching" value (a YAML list or map) must be an
|
|
// ErrInvalidMatchingConfig error naming the value, never a silent fuzzy
|
|
// fallback.
|
|
t.Run("matching as a YAML list is an error naming the value", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching:
|
|
- fuzzy
|
|
- regex
|
|
wording:
|
|
patch:
|
|
- update
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
|
assert.Contains(t, err.Error(), "fuzzy")
|
|
assert.Contains(t, err.Error(), "regex")
|
|
})
|
|
|
|
t.Run("matching as a YAML map is an error naming the value", func(t *testing.T) {
|
|
path := writeTempConfig(t, `
|
|
matching:
|
|
mode: regex
|
|
wording:
|
|
patch:
|
|
- update
|
|
`)
|
|
_, err := ReadConfig(path)
|
|
require.Error(t, err)
|
|
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
|
assert.Contains(t, err.Error(), "mode")
|
|
})
|
|
}
|