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:
2026-09-25 19:35:48 +01:00
parent 9617b281a9
commit e90191de4c
9 changed files with 800 additions and 20 deletions
+27 -1
View File
@@ -21,6 +21,7 @@ Project created overnight, to prove that management of semantic versioning is NO
- [Calculations example \[strict matching\]](#calculations-example-strict-matching)
- [Release candidates](#release-candidates)
- [Tag prefix stripping](#tag-prefix-stripping)
- [Matching modes](#matching-modes)
- [Example configuration](#example-configuration)
- [Good to knows](#good-to-knows)
- [Telemetry](#telemetry)
@@ -238,6 +239,30 @@ This is particularly useful when:
- Your CI/CD creates tags with component prefixes
- You want to track versions separately for different parts of your codebase
#### Matching modes
Config key `matching` controls how each keyword in `wording` is checked against a commit message. Allowed values are `fuzzy` (the default, also used when the key is absent) and `regex`. Any other value is a config error and `semver-gen` exits non-zero rather than silently falling back to `fuzzy`.
**`fuzzy` (default):** the commit message is split on whitespace into words, and a keyword matches as soon as one word contains the keyword's letters *in order*, case-insensitive — it does not need to be a whole word or an exact substring. This is convenient but has a real false-positive risk: keyword `feat` also matches the word `fix(delegation):` (f-e-a-t appear in order inside "fix(delegation):"), and keyword `major` matches `deps(majors):`. Short, generic keywords are the most likely to over-match; test your wording list against real commit messages before relying on it.
**`regex`:** every keyword in `wording.patch` / `wording.minor` / `wording.major` / `wording.release` is a Go RE2 regular expression (the standard library `regexp` package). Patterns are compiled once when the config is loaded — an invalid pattern makes `semver-gen` exit non-zero immediately, naming the wording list and the keyword. Each pattern is matched with `MatchString` against the **full raw commit message** (subject and body, with the original newlines, not the whitespace-split words `fuzzy` uses), so `(?m)^feat(\([^)]*\))?!?:` anchors to the start of a line. Matching is case-sensitive unless the pattern itself uses `(?i)`. The blacklist works exactly the same way in both modes.
Regex patterns must be quoted in YAML (for example `'(?m)^fix:'`), since unquoted `^`, `:` and other YAML-significant characters can break the file's syntax; a YAML syntax error makes the tool fall back to its defaults, the same as it already does for any unreadable config file.
```yaml
matching: regex
wording:
patch:
- '^fix(\([^)]*\))?:'
minor:
- '(?m)^feat(\([^)]*\))?!?:'
major:
# a trailer line anywhere in the commit body, e.g. "Semver-Major: dropped the v1 API"
- '(?mi)^semver-major:'
```
With this config, a subject line of `fix: correct off-by-one` bumps patch, `feat: add widget` bumps minor, and a body trailer of `Semver-Major: ...` bumps major regardless of case. `fix(delegation): route around dead worker` bumps patch too — the patch pattern's `(\([^)]*\))?` group allows an optional scope — but it does NOT bump minor, unlike `fuzzy` mode, where keyword `feat` matches it (see above).
#### Example configuration
```yaml
@@ -276,11 +301,12 @@ wording:
* `force.commit`: allows you to set commit hash from which the calculations should start
* `blacklist`: terms to ignore when processing commits. Any commit containing these terms will be skipped in version calculations. Useful for ignoring merge commits, feature branch names, and other unwanted triggers.
* `tag_prefixes`: prefixes to strip from existing tags before parsing version numbers. Useful for monorepos where tags are prefixed with component names (e.g., `app-1.2.3`, `infra-0.5.0`). The `v` prefix is always stripped automatically.
* `matching`: how `wording` keywords are matched against commit messages — `fuzzy` (default) or `regex`. See [Matching modes](#matching-modes).
* `wording`: words the program should look for in the git commits to increment (patch|minor|major)
### Good to knows
* Word matching uses fuzzy search AND is case INSENSITIVE
* Word matching uses fuzzy search AND is case INSENSITIVE by default; set `matching: regex` for anchored, case-sensitive regular expression matching instead — see [Matching modes](#matching-modes)
* I do not recommend using common words ( like "the" from the example configuration )
* You can specify env variable `LOG_LEVEL=debug` to see what exactly happens during the calculations
+11
View File
@@ -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
View File
@@ -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
}
+198
View File
@@ -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")
})
}
+119
View File
@@ -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)
}
}
+220
View File
@@ -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
View File
@@ -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 {
+147
View File
@@ -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
View File
@@ -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