mirror of
https://github.com/lukaszraczylo/semver-generator.git
synced 2026-10-14 00:58:48 +00:00
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
This commit is contained in:
+11
@@ -18,6 +18,7 @@ limitations under the License.
|
||||
package cmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
@@ -96,6 +97,15 @@ func main() {
|
||||
// Read configuration
|
||||
config, err := utils.ReadConfig(repo.LocalConfigFile)
|
||||
if err != nil {
|
||||
// A bad "matching" value or an invalid regex keyword is a real
|
||||
// misconfiguration, not a missing-file situation: fail loudly
|
||||
// instead of silently falling back to fuzzy matching.
|
||||
if errors.Is(err, utils.ErrInvalidMatchingConfig) {
|
||||
utils.Critical("Invalid matching configuration", map[string]interface{}{
|
||||
"error": err.Error(),
|
||||
})
|
||||
os.Exit(1)
|
||||
}
|
||||
utils.Error("Unable to find config file. Using defaults and flags.", map[string]interface{}{
|
||||
"file": repo.LocalConfigFile,
|
||||
})
|
||||
@@ -140,6 +150,7 @@ func main() {
|
||||
repo.GitRepo.Commits,
|
||||
repo.GitRepo.Tags,
|
||||
repo.Config.Wording,
|
||||
repo.Config.Matcher,
|
||||
repo.Config.Blacklist,
|
||||
repo.Semver,
|
||||
params.varExisting || repo.Config.Force.Existing,
|
||||
|
||||
+44
-4
@@ -30,6 +30,8 @@ type Config struct {
|
||||
Force Force
|
||||
Blacklist []string
|
||||
TagPrefixes []string // Prefixes to strip from tags before parsing (e.g., "app-", "infra-", "v")
|
||||
Matching string // Effective matching mode: MatchingFuzzy (default) or MatchingRegex
|
||||
Matcher KeywordMatcher
|
||||
}
|
||||
|
||||
// ReadConfig reads the configuration from a file
|
||||
@@ -43,19 +45,57 @@ func ReadConfig(file string) (*Config, error) {
|
||||
return config, err
|
||||
}
|
||||
|
||||
// Read "matching" first, right after ReadInConfig, so every later parse
|
||||
// error below can tell whether regex mode is in play. viper.Get (not
|
||||
// GetString) lets us reject a non-scalar value, such as a YAML list or
|
||||
// map, as a config error instead of silently treating it as "fuzzy".
|
||||
switch rawMatching := viper.Get("matching"); v := rawMatching.(type) {
|
||||
case nil:
|
||||
config.Matching = MatchingFuzzy
|
||||
case string:
|
||||
switch v {
|
||||
case "":
|
||||
config.Matching = MatchingFuzzy
|
||||
case MatchingFuzzy, MatchingRegex:
|
||||
config.Matching = v
|
||||
default:
|
||||
return config, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, v, MatchingFuzzy, MatchingRegex)
|
||||
}
|
||||
default:
|
||||
return config, fmt.Errorf("%w: matching config must be a string, got %v (%T)", ErrInvalidMatchingConfig, v, v)
|
||||
}
|
||||
|
||||
// In regex mode there is no fuzzy fallback: any config parse error below
|
||||
// (wording/force/blacklist/tag_prefixes) is a real misconfiguration, so
|
||||
// wrap it with ErrInvalidMatchingConfig, which main.go treats as fatal.
|
||||
// In fuzzy mode (default), behaviour is unchanged: the error is returned
|
||||
// as-is and the caller logs it and continues with defaults.
|
||||
wrapIfRegex := func(err error) error {
|
||||
if err == nil || config.Matching != MatchingRegex {
|
||||
return err
|
||||
}
|
||||
return fmt.Errorf("%w: %v", ErrInvalidMatchingConfig, err)
|
||||
}
|
||||
|
||||
if err := viper.UnmarshalKey("wording", &config.Wording); err != nil {
|
||||
return config, fmt.Errorf("error parsing wording config: %w", err)
|
||||
return config, wrapIfRegex(fmt.Errorf("error parsing wording config: %w", err))
|
||||
}
|
||||
if err := viper.UnmarshalKey("force", &config.Force); err != nil {
|
||||
return config, fmt.Errorf("error parsing force config: %w", err)
|
||||
return config, wrapIfRegex(fmt.Errorf("error parsing force config: %w", err))
|
||||
}
|
||||
if err := viper.UnmarshalKey("blacklist", &config.Blacklist); err != nil {
|
||||
return config, fmt.Errorf("error parsing blacklist config: %w", err)
|
||||
return config, wrapIfRegex(fmt.Errorf("error parsing blacklist config: %w", err))
|
||||
}
|
||||
if err := viper.UnmarshalKey("tag_prefixes", &config.TagPrefixes); err != nil {
|
||||
return config, fmt.Errorf("error parsing tag_prefixes config: %w", err)
|
||||
return config, wrapIfRegex(fmt.Errorf("error parsing tag_prefixes config: %w", err))
|
||||
}
|
||||
|
||||
matcher, err := NewKeywordMatcher(config.Matching, config.Wording)
|
||||
if err != nil {
|
||||
return config, err
|
||||
}
|
||||
config.Matcher = matcher
|
||||
|
||||
return config, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
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) {
|
||||
@@ -199,3 +202,198 @@ wording:
|
||||
_, 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")
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
// Package utils implements semver-gen's configuration loading, git access,
|
||||
// commit-keyword matching (fuzzy and regex) and version calculation.
|
||||
package utils
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Matching mode values accepted by the "matching" config key.
|
||||
const (
|
||||
MatchingFuzzy = "fuzzy"
|
||||
MatchingRegex = "regex"
|
||||
)
|
||||
|
||||
// ErrInvalidMatchingConfig is wrapped by any error NewKeywordMatcher returns.
|
||||
// Callers use errors.Is to tell a bad "matching" value or a bad regex keyword
|
||||
// apart from other, non-fatal config problems.
|
||||
var ErrInvalidMatchingConfig = errors.New("invalid matching configuration")
|
||||
|
||||
// KeywordMatcher decides whether a commit message matches one of a wording
|
||||
// list's keywords, honoring the blacklist. CalculateSemver picks one
|
||||
// implementation, chosen once from config, instead of branching on the
|
||||
// matching mode itself.
|
||||
type KeywordMatcher interface {
|
||||
Matches(commitMessage string, keywords []string, blacklist []string) bool
|
||||
}
|
||||
|
||||
// FuzzyMatcher reproduces the original matching behaviour: the commit message
|
||||
// is split into whitespace-separated words and each keyword is fuzzy-matched
|
||||
// (letters in order, case-insensitive) against those words via FuzzyFind.
|
||||
type FuzzyMatcher struct{}
|
||||
|
||||
// Matches implements KeywordMatcher.
|
||||
func (FuzzyMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
|
||||
return CheckMatches(strings.Fields(commitMessage), keywords, blacklist)
|
||||
}
|
||||
|
||||
// RegexMatcher matches keywords, each a Go RE2 regular expression compiled
|
||||
// once up front, against the full raw commit message (subject and body,
|
||||
// newlines preserved).
|
||||
type RegexMatcher struct {
|
||||
compiled map[string]*regexp.Regexp
|
||||
}
|
||||
|
||||
// Matches implements KeywordMatcher.
|
||||
func (m RegexMatcher) Matches(commitMessage string, keywords []string, blacklist []string) bool {
|
||||
hasMatch := false
|
||||
for _, keyword := range keywords {
|
||||
re, ok := m.compiled[keyword]
|
||||
if !ok {
|
||||
// NewKeywordMatcher compiles every keyword of every wording list up
|
||||
// front, so this means Matches was called with a keyword that
|
||||
// wasn't part of the wording the matcher was built from. Log it
|
||||
// loudly instead of silently skipping, and treat it as no-match
|
||||
// rather than panicking on a nil regexp.
|
||||
Error("Regex keyword was never compiled; treating as no-match", map[string]interface{}{
|
||||
"keyword": keyword,
|
||||
})
|
||||
continue
|
||||
}
|
||||
if re.MatchString(commitMessage) {
|
||||
hasMatch = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if !hasMatch {
|
||||
return false
|
||||
}
|
||||
|
||||
// Match blacklist semantics exactly the same way CheckMatches (fuzzy mode)
|
||||
// does: whitespace-normalized content, so a blacklist term split across a
|
||||
// newline or tab in the raw commit message still suppresses the match.
|
||||
contentStr := strings.Join(strings.Fields(commitMessage), " ")
|
||||
return !isBlacklisted(contentStr, blacklist)
|
||||
}
|
||||
|
||||
// NewKeywordMatcher builds the KeywordMatcher selected by matching ("" and
|
||||
// "fuzzy" both mean fuzzy, "regex" means regex; anything else is an error).
|
||||
// In regex mode every keyword across all four wording lists is compiled
|
||||
// exactly once here, so a bad pattern is caught at config-load time instead
|
||||
// of failing mid-run. Compile and validation errors wrap
|
||||
// ErrInvalidMatchingConfig so callers can fail loudly on them.
|
||||
func NewKeywordMatcher(matching string, wording Wording) (KeywordMatcher, error) {
|
||||
switch matching {
|
||||
case "", MatchingFuzzy:
|
||||
return FuzzyMatcher{}, nil
|
||||
case MatchingRegex:
|
||||
wordingLists := []struct {
|
||||
name string
|
||||
keywords []string
|
||||
}{
|
||||
{"patch", wording.Patch},
|
||||
{"minor", wording.Minor},
|
||||
{"major", wording.Major},
|
||||
{"release", wording.Release},
|
||||
}
|
||||
|
||||
compiled := make(map[string]*regexp.Regexp)
|
||||
for _, list := range wordingLists {
|
||||
for _, keyword := range list.keywords {
|
||||
if _, alreadyCompiled := compiled[keyword]; alreadyCompiled {
|
||||
continue
|
||||
}
|
||||
re, err := regexp.Compile(keyword)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%w: invalid regex in wording.%s keyword %q: %v", ErrInvalidMatchingConfig, list.name, keyword, err)
|
||||
}
|
||||
compiled[keyword] = re
|
||||
}
|
||||
}
|
||||
return RegexMatcher{compiled: compiled}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("%w: invalid matching value %q, allowed values are %q and %q", ErrInvalidMatchingConfig, matching, MatchingFuzzy, MatchingRegex)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
package utils
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestNewKeywordMatcher(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
wording := Wording{
|
||||
Patch: []string{"^fix:"},
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
Major: []string{"(?mi)^semver-major:"},
|
||||
}
|
||||
|
||||
t.Run("empty matching defaults to fuzzy", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher("", wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, FuzzyMatcher{}, m)
|
||||
// Behavioural check, not just the type: fuzzy matching means
|
||||
// "fix(delegation): x" still matches keyword "feat" (f-e-a-t appear
|
||||
// in order inside "fix(delegation):"), 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, m.Matches("fix(delegation): x", []string{"feat"}, nil))
|
||||
})
|
||||
|
||||
t.Run("fuzzy", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingFuzzy, wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, FuzzyMatcher{}, m)
|
||||
|
||||
// Behavioural check, not just the type: same as the empty-matching
|
||||
// subtest above, using the real fuzzy library.
|
||||
originalFuzzyFind := FuzzyFind
|
||||
t.Cleanup(func() { FuzzyFind = originalFuzzyFind })
|
||||
FuzzyFind = fuzzy.FindNormalizedFold
|
||||
assert.True(t, m.Matches("fix(delegation): x", []string{"feat"}, nil))
|
||||
})
|
||||
|
||||
t.Run("regex compiles every keyword once", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, wording)
|
||||
assert.NoError(t, err)
|
||||
assert.IsType(t, RegexMatcher{}, m)
|
||||
// Behavioural check: the same input that fuzzy-matches "feat" does not
|
||||
// match the anchored regex pattern in wording.Minor.
|
||||
assert.False(t, m.Matches("fix(delegation): x", wording.Minor, nil))
|
||||
})
|
||||
|
||||
t.Run("unknown value errors naming value and allowed values", func(t *testing.T) {
|
||||
_, err := NewKeywordMatcher("weird", wording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "weird")
|
||||
assert.Contains(t, err.Error(), MatchingFuzzy)
|
||||
assert.Contains(t, err.Error(), MatchingRegex)
|
||||
})
|
||||
|
||||
t.Run("invalid regex errors naming wording list and keyword", func(t *testing.T) {
|
||||
badWording := Wording{Major: []string{"[unterminated("}}
|
||||
_, err := NewKeywordMatcher(MatchingRegex, badWording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "major")
|
||||
assert.Contains(t, err.Error(), "[unterminated(")
|
||||
})
|
||||
|
||||
t.Run("invalid release regex errors naming wording list and keyword", func(t *testing.T) {
|
||||
badWording := Wording{Release: []string{"[unterminated("}}
|
||||
_, err := NewKeywordMatcher(MatchingRegex, badWording)
|
||||
require.Error(t, err)
|
||||
require.ErrorIs(t, err, ErrInvalidMatchingConfig)
|
||||
assert.Contains(t, err.Error(), "release")
|
||||
assert.Contains(t, err.Error(), "[unterminated(")
|
||||
})
|
||||
}
|
||||
|
||||
func TestRegexMatcherMatches(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
t.Run("anchored feat subject matches", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("feat: add widget\n\nsome body text", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("fix(delegation) subject does not match anchored feat pattern", func(t *testing.T) {
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{
|
||||
Minor: []string{`(?m)^feat(\([^)]*\))?!?:`},
|
||||
})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("fix(delegation): route around dead worker", []string{`(?m)^feat(\([^)]*\))?!?:`}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("body trailer Semver-Major matches multiline case-insensitive pattern", func(t *testing.T) {
|
||||
pattern := "(?mi)^semver-major:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "fix: patch a bug\n\nDetails here.\n\nSemver-Major: breaking storage format\n"
|
||||
got := m.Matches(message, []string{pattern}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("feat! subject does not match a trailer-only major pattern", func(t *testing.T) {
|
||||
pattern := "(?mi)^semver-major:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("feat!: rework the API\n\nno trailer here", []string{pattern}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("case sensitivity: pattern without (?i) does not match different case", func(t *testing.T) {
|
||||
pattern := "^FEAT:"
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, m.Matches("feat: lowercase subject", []string{pattern}, nil))
|
||||
assert.True(t, m.Matches("FEAT: uppercase subject", []string{pattern}, nil))
|
||||
})
|
||||
|
||||
t.Run("blacklist still suppresses a regex match", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "feat: add widget [skip-ci]"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil))
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip-ci"}))
|
||||
})
|
||||
|
||||
t.Run("blacklist term split by a newline still suppresses, same as fuzzy mode", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
// Blacklist term is "skip ci", but the raw commit message has it split
|
||||
// across a newline: "skip\nci". CheckMatches (fuzzy mode) would still
|
||||
// catch this because it joins strings.Fields(msg) with a single space
|
||||
// before the substring check; the regex-mode blacklist check must do
|
||||
// the same.
|
||||
message := "feat: add widget\n\nskip\nci"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
|
||||
})
|
||||
|
||||
t.Run("blacklist term split by a tab still suppresses, same as fuzzy mode", func(t *testing.T) {
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
message := "feat: add widget\n\nskip\tci"
|
||||
assert.True(t, m.Matches(message, []string{pattern}, nil), "sanity: matches without blacklist")
|
||||
assert.False(t, m.Matches(message, []string{pattern}, []string{"skip ci"}))
|
||||
})
|
||||
|
||||
t.Run("full-message anchoring: mid-message keyword-looking text does not match anchored pattern", func(t *testing.T) {
|
||||
// Discriminating case: if RegexMatcher ran MatchString per
|
||||
// strings.Fields word instead of against the full raw message, the
|
||||
// word "feat:" here would match "(?m)^feat...:" on its own (it starts
|
||||
// a "word"), even though the actual commit subject starts with
|
||||
// "docs:". Matching against the full message must not match.
|
||||
pattern := `(?m)^feat(\([^)]*\))?!?:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("docs: explain feat: usage", []string{pattern}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
|
||||
t.Run("full-message anchoring: multiline trailer matches (?m) anchor on its own line", func(t *testing.T) {
|
||||
// Discriminating case: strings.Fields on this message splits "BREAKING"
|
||||
// and "CHANGE:" into separate words, neither of which starts with
|
||||
// "BREAKING CHANGE:", so a per-word implementation would miss this.
|
||||
// Matching against the full raw message (newlines kept) must match,
|
||||
// because (?m) anchors "^" to the start of the trailer's own line.
|
||||
pattern := `(?m)^BREAKING CHANGE:`
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Major: []string{pattern}})
|
||||
assert.NoError(t, err)
|
||||
got := m.Matches("refactor: x\n\nBREAKING CHANGE: drop v1", []string{pattern}, nil)
|
||||
assert.True(t, got)
|
||||
})
|
||||
|
||||
t.Run("keyword missing from compiled map is a no-match, not a panic", func(t *testing.T) {
|
||||
// The matcher was built from wording.Minor only; calling Matches with
|
||||
// an unrelated keyword exercises the "not compiled" branch. It must
|
||||
// log loudly (via the package's Error logger) rather than silently
|
||||
// return, but it must not miss the check or panic on a nil regexp.
|
||||
m, err := NewKeywordMatcher(MatchingRegex, Wording{Minor: []string{`(?m)^feat:`}})
|
||||
assert.NoError(t, err)
|
||||
assert.NotPanics(t, func() {
|
||||
got := m.Matches("feat: add widget", []string{"^never-compiled:"}, nil)
|
||||
assert.False(t, got)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
func TestFuzzyMatcherMatches(t *testing.T) {
|
||||
InitLogger(false)
|
||||
|
||||
originalFuzzyFind := FuzzyFind
|
||||
defer func() { FuzzyFind = originalFuzzyFind }()
|
||||
FuzzyFind = func(needle string, haystack []string) []string {
|
||||
for _, h := range haystack {
|
||||
if h == needle {
|
||||
return []string{h}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
m := FuzzyMatcher{}
|
||||
assert.True(t, m.Matches("update dependencies", []string{"update", "fix"}, nil))
|
||||
assert.False(t, m.Matches("chore dependencies", []string{"update", "fix"}, nil))
|
||||
assert.False(t, m.Matches("update dependencies skip-ci", []string{"update", "fix"}, []string{"skip-ci"}))
|
||||
}
|
||||
+11
-5
@@ -9,12 +9,19 @@ func CalculateSemver(
|
||||
commits []CommitDetails,
|
||||
tags []TagDetails,
|
||||
wording Wording,
|
||||
matcher KeywordMatcher,
|
||||
blacklist []string,
|
||||
initialSemver SemVer,
|
||||
respectExisting bool,
|
||||
strictMode bool,
|
||||
tagPrefixes []string,
|
||||
) SemVer {
|
||||
// Default-preserving: callers that don't select a matcher get today's
|
||||
// fuzzy behaviour rather than a nil-pointer panic.
|
||||
if matcher == nil {
|
||||
matcher = FuzzyMatcher{}
|
||||
}
|
||||
|
||||
semver := initialSemver
|
||||
startIndex := 0
|
||||
|
||||
@@ -57,11 +64,10 @@ func CalculateSemver(
|
||||
}
|
||||
|
||||
// Check for keyword matches
|
||||
commitSlice := strings.Fields(commit.Message)
|
||||
matchPatch := CheckMatches(commitSlice, wording.Patch, blacklist)
|
||||
matchMinor := CheckMatches(commitSlice, wording.Minor, blacklist)
|
||||
matchMajor := CheckMatches(commitSlice, wording.Major, blacklist)
|
||||
matchReleaseCandidate := CheckMatches(commitSlice, wording.Release, blacklist)
|
||||
matchPatch := matcher.Matches(commit.Message, wording.Patch, blacklist)
|
||||
matchMinor := matcher.Matches(commit.Message, wording.Minor, blacklist)
|
||||
matchMajor := matcher.Matches(commit.Message, wording.Major, blacklist)
|
||||
matchReleaseCandidate := matcher.Matches(commit.Message, wording.Release, blacklist)
|
||||
|
||||
// Apply version changes based on matches
|
||||
if matchMajor {
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lithammer/fuzzysearch/fuzzy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
@@ -281,6 +282,7 @@ func TestCalculateSemver(t *testing.T) {
|
||||
tt.commits,
|
||||
tt.tags,
|
||||
tt.wording,
|
||||
FuzzyMatcher{},
|
||||
tt.blacklist,
|
||||
tt.initialSemver,
|
||||
tt.respectExisting,
|
||||
@@ -296,3 +298,148 @@ func TestCalculateSemver(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
|
||||
+23
-10
@@ -166,6 +166,27 @@ func ParseExistingSemver(tagName string, currentSemver SemVer, prefixes []string
|
||||
return semanticVersion
|
||||
}
|
||||
|
||||
// isBlacklisted reports whether contentStr contains any blacklist term
|
||||
// (case-insensitive substring match), logging the same way every caller used
|
||||
// to log it inline. Both matching modes call this so blacklist semantics stay
|
||||
// identical between fuzzy and regex matching.
|
||||
func isBlacklisted(contentStr string, blacklist []string) bool {
|
||||
if len(blacklist) == 0 {
|
||||
return false
|
||||
}
|
||||
lowerContent := strings.ToLower(contentStr)
|
||||
for _, blacklistTerm := range blacklist {
|
||||
if strings.Contains(lowerContent, strings.ToLower(blacklistTerm)) {
|
||||
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
|
||||
"content": contentStr,
|
||||
"blacklist_term": blacklistTerm,
|
||||
})
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// CheckMatches checks if any of the targets match the content
|
||||
func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
contentStr := strings.Join(content, " ")
|
||||
@@ -186,16 +207,8 @@ func CheckMatches(content []string, targets []string, blacklist []string) bool {
|
||||
}
|
||||
|
||||
// If we have a match, check against blacklist
|
||||
if hasMatch && len(blacklist) > 0 {
|
||||
for _, blacklistTerm := range blacklist {
|
||||
if strings.Contains(strings.ToLower(contentStr), strings.ToLower(blacklistTerm)) {
|
||||
Debug("Blacklisted term detected, ignoring commit", map[string]interface{}{
|
||||
"content": contentStr,
|
||||
"blacklist_term": blacklistTerm,
|
||||
})
|
||||
return false
|
||||
}
|
||||
}
|
||||
if hasMatch && isBlacklisted(contentStr, blacklist) {
|
||||
return false
|
||||
}
|
||||
|
||||
return hasMatch
|
||||
|
||||
Reference in New Issue
Block a user