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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user