Files
semver-generator/cmd/utils/config.go
T
lukaszraczylo e90191de4c 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
2026-09-25 19:35:48 +01:00

119 lines
3.6 KiB
Go

package utils
import (
"fmt"
"github.com/spf13/viper"
)
// Wording represents the keywords to look for in commit messages
type Wording struct {
Patch []string
Minor []string
Major []string
Release []string
}
// Force represents forced versioning settings
type Force struct {
Commit string
Patch int
Minor int
Major int
Existing bool
Strict bool
}
// Config represents the application configuration
type Config struct {
Wording Wording
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
func ReadConfig(file string) (*Config, error) {
config := &Config{}
viper.SetConfigFile(file)
err := viper.ReadInConfig()
if err != nil {
err = fmt.Errorf("fatal error config file: %s", err)
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, wrapIfRegex(fmt.Errorf("error parsing wording config: %w", err))
}
if err := viper.UnmarshalKey("force", &config.Force); err != nil {
return config, wrapIfRegex(fmt.Errorf("error parsing force config: %w", err))
}
if err := viper.UnmarshalKey("blacklist", &config.Blacklist); err != nil {
return config, wrapIfRegex(fmt.Errorf("error parsing blacklist config: %w", err))
}
if err := viper.UnmarshalKey("tag_prefixes", &config.TagPrefixes); err != nil {
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
}
// ApplyForcedVersioning applies forced versioning settings to a semantic version
func ApplyForcedVersioning(force Force, semver *SemVer) {
if force.Major > 0 {
Debug("Forced versioning (MAJOR)", map[string]interface{}{"major": force.Major})
semver.Major = force.Major
}
if force.Minor > 0 {
Debug("Forced versioning (MINOR)", map[string]interface{}{"minor": force.Minor})
semver.Minor = force.Minor
}
if force.Patch > 0 {
Debug("Forced versioning (PATCH)", map[string]interface{}{"patch": force.Patch})
semver.Patch = force.Patch
}
}