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
+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")
})
}