From 7bb8a71e7ba74ee73050e27620850fe7d1dbf628 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 07:01:43 +0300 Subject: [PATCH 1/9] test: Add config load tests --- pkg/validator/config_test.go | 89 ++++++++++++++++++++++++++++++++++++ 1 file changed, 89 insertions(+) create mode 100644 pkg/validator/config_test.go diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go new file mode 100644 index 0000000..eb1ad9e --- /dev/null +++ b/pkg/validator/config_test.go @@ -0,0 +1,89 @@ +package validator_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/thumbrise/commitlint-scope/pkg/validator" +) + +func TestLoadConfig(t *testing.T) { + defaultRegexStr := `^[a-z]+(?:\((?P[^)]+)\))?!?:\s` + + tests := []struct { + name string + yaml string + wantRegexNil bool + wantRegex string + wantPatterns map[string][]string + }{ + { + name: "no config file", + wantRegexNil: true, + }, + { + name: "patterns with default regex", + yaml: `patterns: + api: + - api/* + core: + - core/** +`, + wantRegex: defaultRegexStr, + wantPatterns: map[string][]string{ + "api": {"api/*"}, + "core": {"core/**"}, + }, + }, + { + name: "custom scopeRegex only", + yaml: `scopeRegex: '^(feat|fix):' +`, + wantRegex: `^(feat|fix):`, + }, + { + name: "both patterns and custom scopeRegex", + yaml: `scopeRegex: '^(feat|fix):' +patterns: + api: + - api/* +`, + wantRegex: `^(feat|fix):`, + wantPatterns: map[string][]string{ + "api": {"api/*"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + dir := t.TempDir() + origDir, err := os.Getwd() + require.NoError(t, err) + + defer os.Chdir(origDir) + + os.Chdir(dir) + + if tt.yaml != "" { + err = os.WriteFile(filepath.Join(dir, ".commitlint-scope.yaml"), []byte(tt.yaml), 0o644) + require.NoError(t, err) + } + + cfg, err := validator.LoadConfig() + require.NoError(t, err) + + if tt.wantRegexNil { + assert.Nil(t, cfg.ScopeRegex) + } else { + require.NotNil(t, cfg.ScopeRegex) + assert.Equal(t, tt.wantRegex, cfg.ScopeRegex.String()) + } + + assert.Equal(t, tt.wantPatterns, cfg.Patterns) + }) + } +} From 4d6d8561e03f0e53877161ae128f66f884ccef8e Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 07:01:59 +0300 Subject: [PATCH 2/9] chore: Add build task --- .gitignore | 2 ++ Taskfile.yaml | 6 ++++++ 2 files changed, 8 insertions(+) diff --git a/.gitignore b/.gitignore index 7a1537b..28f3a73 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,4 @@ .idea node_modules +commitlint-scope +build/* diff --git a/Taskfile.yaml b/Taskfile.yaml index c6e5e05..723ef53 100644 --- a/Taskfile.yaml +++ b/Taskfile.yaml @@ -24,3 +24,9 @@ tasks: - gen cmds: - go tool mockery + + build: + desc: Build + cmds: + - mkdir -p build + - go build -o build/ From 581c448f53c50745e9f9d020a3a5d8468522f679 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 07:02:15 +0300 Subject: [PATCH 3/9] test: Add composed scope case in scope_parser_test.go --- pkg/validator/scope_parser_test.go | 17 ++++++++--------- 1 file changed, 8 insertions(+), 9 deletions(-) diff --git a/pkg/validator/scope_parser_test.go b/pkg/validator/scope_parser_test.go index 201d1f3..edc96ad 100644 --- a/pkg/validator/scope_parser_test.go +++ b/pkg/validator/scope_parser_test.go @@ -8,8 +8,6 @@ import ( ) func TestDefaultScopeParser_Parse(t *testing.T) { - compile := regexp.MustCompile - tests := []struct { name string regex *regexp.Regexp @@ -17,13 +15,14 @@ func TestDefaultScopeParser_Parse(t *testing.T) { want string }{ {name: "nil regex", regex: nil, message: "feat(api): add", want: ""}, - {name: "no match", regex: compile(`^feat\((?P[^)]+)\)`), message: "fix(api): bug", want: ""}, - {name: "match with scope", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"}, - {name: "no scope in message", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "chore: update deps", want: ""}, - {name: "regex without named group scope", regex: compile(`^[a-z]+(?:\(([^)]+)\))?!?:\s`), message: "feat(api): add", want: ""}, - {name: "scope with breaking change (!)", regex: compile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"}, - {name: "multiple matches, returns first", regex: compile(`(?P[a-z]+)`), message: "api handler", want: "api"}, - {name: "empty scope in parentheses", regex: compile(`^feat\((?P[^)]*)\)`), message: "feat(): empty", want: ""}, + {name: "no match", regex: regexp.MustCompile(`^feat\((?P[^)]+)\)`), message: "fix(api): bug", want: ""}, + {name: "match with scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "feat(api): add endpoint", want: "api"}, + {name: "no scope in message", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "chore: update deps", want: ""}, + {name: "regex without named group scope", regex: regexp.MustCompile(`^[a-z]+(?:\(([^)]+)\))?!?:\s`), message: "feat(api): add", want: ""}, + {name: "scope with breaking change (!)", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "fix(auth)!: correct token", want: "auth"}, + {name: "multiple matches, returns first", regex: regexp.MustCompile(`(?P[a-z]+)`), message: "api handler", want: "api"}, + {name: "empty scope in parentheses", regex: regexp.MustCompile(`^feat\((?P[^)]*)\)`), message: "feat(): empty", want: ""}, + {name: "composed scope", regex: regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`), message: "style(services,frontend): Add linters and formatters, format whole frontend", want: "services,frontend"}, } for _, tt := range tests { From 5ad6d141584b83f955bf4b9a816b1006e82690e6 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 07:17:57 +0300 Subject: [PATCH 4/9] fix: No more early return if config not found - apply default config values --- pkg/validator/config.go | 12 ++++++------ pkg/validator/config_test.go | 4 ++-- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/pkg/validator/config.go b/pkg/validator/config.go index 6c42ba3..7a40024 100644 --- a/pkg/validator/config.go +++ b/pkg/validator/config.go @@ -17,22 +17,22 @@ type Config struct { Patterns map[string][]string `mapstructure:"patterns"` } +var ErrConfigRead = errors.New("error reading config") + func LoadConfig() (Config, error) { v := viper.New() v.SetConfigName(configName) v.AddConfigPath(".") v.SetDefault("scopeRegex", regexp.MustCompile(`^[a-z]+(?:\((?P[^)]+)\))?!?:\s`)) + var cfg Config + if err := v.ReadInConfig(); err != nil { - if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); ok { - return Config{}, nil + if _, ok := errors.AsType[viper.ConfigFileNotFoundError](err); !ok { + return Config{}, fmt.Errorf("%w: %w", ErrConfigRead, err) } - - return Config{}, err } - var cfg Config - if err := v.Unmarshal(&cfg, regexDecode); err != nil { return Config{}, err } diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go index eb1ad9e..dcfae32 100644 --- a/pkg/validator/config_test.go +++ b/pkg/validator/config_test.go @@ -21,8 +21,8 @@ func TestLoadConfig(t *testing.T) { wantPatterns map[string][]string }{ { - name: "no config file", - wantRegexNil: true, + name: "no config file", + wantRegex: defaultRegexStr, }, { name: "patterns with default regex", From d75cecbc11ade051b15169eab721dced1f569b1f Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 08:34:56 +0300 Subject: [PATCH 5/9] feat: Change Validator logs to debug level --- pkg/validator/validator.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index 2320197..c2f8405 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -105,14 +105,14 @@ func (v *Validator) Validate(ctx context.Context, from, to string) ([]Violation, } if message == "" { - v.logger.Info("no message, skip", "sha", sha) + v.logger.Debug("no message, skip", "sha", sha) continue } scope := v.scopeParser.Parse(message) if scope == "" { - v.logger.Info("no scope, skip", "sha", sha, "message", message) + v.logger.Debug("no scope, skip", "sha", sha, "message", message) continue } @@ -123,7 +123,7 @@ func (v *Validator) Validate(ctx context.Context, from, to string) ([]Violation, } if len(files) == 0 { - v.logger.Info("no files changed, skip", "sha", sha) + v.logger.Debug("no files changed, skip", "sha", sha) continue } From e7d0903614f97f573634b34597d57aefe57ba111 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 08:36:57 +0300 Subject: [PATCH 6/9] feat: Add JSON, color, verbose output modes, add text output for default UX, configure logger AddSource dynamically --- cmd/root.go | 93 +++++++++++++++++++++++++++++++++++++++++++++++------ go.mod | 1 + go.sum | 2 ++ main.go | 14 -------- 4 files changed, 86 insertions(+), 24 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index 052fa9f..cd8804f 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -4,15 +4,21 @@ import ( "context" "encoding/json" "fmt" + "io" "log/slog" + "os" + "github.com/fatih/color" "github.com/thumbrise/commitlint-scope/pkg/validator" "github.com/urfave/cli/v3" ) var ( - from string - to string + flagFrom string + flagTo string + flagVerbose bool + flagNoColor bool + flagJSON bool ) // Root is the entry point command for commitlint-scope. @@ -36,18 +42,41 @@ Examples: Aliases: []string{"f"}, Usage: "Start of the commit range (exclusive)", Required: true, - Destination: &from, + Destination: &flagFrom, }, &cli.StringFlag{ Name: "to", Aliases: []string{"t"}, Usage: "End of the commit range (inclusive)", Required: true, - Destination: &to, + Destination: &flagTo, + }, + &cli.BoolFlag{ + Name: "verbose", + Aliases: []string{"v"}, + Usage: "Verbose output", + Required: false, + Destination: &flagVerbose, + }, + &cli.BoolFlag{ + Name: "no-color", + Usage: "Disable color output", + Required: false, + Destination: &flagNoColor, + }, + &cli.BoolFlag{ + Name: "json", + Usage: "Show output in JSON format", + Required: false, + Destination: &flagJSON, }, }, Action: func(ctx context.Context, cmd *cli.Command) error { + color.NoColor = flagNoColor + + configureLogger(os.Stderr, flagVerbose) + cfg, err := validator.LoadConfig() if err != nil { return fmt.Errorf("loading config: %w", err) @@ -64,7 +93,7 @@ Examples: return fmt.Errorf("creating validator: %w", err) } - violations, err := vld.Validate(ctx, from, to) + violations, err := vld.Validate(ctx, flagFrom, flagTo) if err != nil { return fmt.Errorf("validation failed: %w", err) } @@ -73,15 +102,59 @@ Examples: return nil } - encoder := json.NewEncoder(cmd.Writer) - encoder.SetIndent("", " ") + msg := fmt.Sprintf("%d violation(s) found:", len(violations)) + _, _ = color.New(color.FgRed, color.Bold).Fprintln(cmd.ErrWriter, msg) - for _, v := range violations { - if err := encoder.Encode(v); err != nil { - return fmt.Errorf("failed to output violation: %w", err) + if flagJSON { + err := jsonOutput(cmd.Writer, violations) + if err != nil { + return err } + } else { + textOutput(cmd.Writer, violations) } return nil }, } + +func textOutput(w io.Writer, violations []validator.Violation) { + shaColor := color.New(color.FgYellow, color.Bold) + for _, v := range violations { + _, _ = fmt.Fprintf(w, "%s %s\n", shaColor.Sprintf("%s", v.SHA), v.Header) + for _, o := range v.Outsiders { + _, _ = fmt.Fprintf(w, " - %s\n", o.File) + } + } +} + +func jsonOutput(writer io.Writer, violations []validator.Violation) error { + encoder := json.NewEncoder(writer) + encoder.SetIndent("", " ") + + for _, v := range violations { + if err := encoder.Encode(v); err != nil { + return fmt.Errorf("failed to output violation: %w", err) + } + } + + return nil +} + +func configureLogger(writer io.Writer, verbose bool) { + opts := &slog.HandlerOptions{ + AddSource: false, + Level: slog.LevelInfo, + } + + if verbose { + opts.Level = slog.LevelDebug + opts.AddSource = true + } + + handler := slog.NewTextHandler(writer, opts) + + logger := slog.New(handler) + + slog.SetDefault(logger) +} diff --git a/go.mod b/go.mod index d3ec1a7..6762d91 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/thumbrise/commitlint-scope go 1.26.1 require ( + github.com/fatih/color v1.19.0 github.com/go-viper/mapstructure/v2 v2.5.0 github.com/gobwas/glob v0.2.3 github.com/spf13/viper v1.21.0 diff --git a/go.sum b/go.sum index 3f6bdb8..5d4f527 100644 --- a/go.sum +++ b/go.sum @@ -7,6 +7,8 @@ github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6N github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= +github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/main.go b/main.go index daba7e8..4290524 100644 --- a/main.go +++ b/main.go @@ -3,7 +3,6 @@ package main import ( "context" "fmt" - "log/slog" "os" "github.com/thumbrise/commitlint-scope/cmd" @@ -12,22 +11,9 @@ import ( func main() { ctx := context.Background() - configureLogger() - if err := cmd.Root.Run(ctx, os.Args); err != nil { _, _ = fmt.Fprintf(os.Stderr, "error: %[1]v\n", err) os.Exit(1) } } - -func configureLogger() { - handler := slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{ - AddSource: true, - Level: nil, - }) - - logger := slog.New(handler) - - slog.SetDefault(logger) -} From 3c9d4ba5858c78e185e9ed173db10399856e54b8 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 08:46:37 +0300 Subject: [PATCH 7/9] fix: Merge json outputs in one json --- cmd/root.go | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/cmd/root.go b/cmd/root.go index cd8804f..9b76474 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -132,10 +132,8 @@ func jsonOutput(writer io.Writer, violations []validator.Violation) error { encoder := json.NewEncoder(writer) encoder.SetIndent("", " ") - for _, v := range violations { - if err := encoder.Encode(v); err != nil { - return fmt.Errorf("failed to output violation: %w", err) - } + if err := encoder.Encode(violations); err != nil { + return fmt.Errorf("failed to output violations: %w", err) } return nil From bc2500786c024c30bd42b1f53a7d79ee5815c477 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 08:49:16 +0300 Subject: [PATCH 8/9] test: Replace os.Chdir defer with t.Cleanup in config_test.go --- pkg/validator/config_test.go | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go index dcfae32..ba509c8 100644 --- a/pkg/validator/config_test.go +++ b/pkg/validator/config_test.go @@ -64,9 +64,11 @@ patterns: origDir, err := os.Getwd() require.NoError(t, err) - defer os.Chdir(origDir) + t.Cleanup(func() { + require.NoError(t, os.Chdir(origDir)) + }) - os.Chdir(dir) + require.NoError(t, os.Chdir(dir)) if tt.yaml != "" { err = os.WriteFile(filepath.Join(dir, ".commitlint-scope.yaml"), []byte(tt.yaml), 0o644) From 55b71268e2012740096c02ffadf4c7461f734fe3 Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Thu, 28 May 2026 08:52:30 +0300 Subject: [PATCH 9/9] test: Add malformed config test case in config_test.go --- pkg/validator/config.go | 2 +- pkg/validator/config_test.go | 13 +++++++++++++ 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/pkg/validator/config.go b/pkg/validator/config.go index 7a40024..ef027c0 100644 --- a/pkg/validator/config.go +++ b/pkg/validator/config.go @@ -34,7 +34,7 @@ func LoadConfig() (Config, error) { } if err := v.Unmarshal(&cfg, regexDecode); err != nil { - return Config{}, err + return Config{}, fmt.Errorf("%w: %w", ErrConfigRead, err) } return cfg, nil diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go index ba509c8..b22656a 100644 --- a/pkg/validator/config_test.go +++ b/pkg/validator/config_test.go @@ -19,6 +19,7 @@ func TestLoadConfig(t *testing.T) { wantRegexNil bool wantRegex string wantPatterns map[string][]string + wantErr error }{ { name: "no config file", @@ -56,6 +57,11 @@ patterns: "api": {"api/*"}, }, }, + { + name: "malformed config", + yaml: "[[invalid", + wantErr: validator.ErrConfigRead, + }, } for _, tt := range tests { @@ -76,6 +82,13 @@ patterns: } cfg, err := validator.LoadConfig() + if tt.wantErr != nil { + require.Error(t, err) + assert.ErrorIs(t, err, tt.wantErr) + + return + } + require.NoError(t, err) if tt.wantRegexNil {