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
120 lines
4.2 KiB
Go
120 lines
4.2 KiB
Go
// 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)
|
|
}
|
|
}
|