From 24aae1c97977fe3082644b64795ec3d873b194fd Mon Sep 17 00:00:00 2001 From: Ruslan Kokoev Date: Fri, 29 May 2026 07:51:37 +0300 Subject: [PATCH] feat!: Scopes-Patterns matrix support, special chars in scopes support --- README.md | 24 +++++++--- cmd/commands/init.go | 24 +++++++--- cmd/commands/run.go | 4 +- cmd/root.go | 2 +- docs/schema/config.v3.json | 44 ++++++++++++++++++ go.mod | 2 +- main.go | 2 +- pkg/validator/config.go | 9 +++- pkg/validator/config_test.go | 37 +++++++++------ pkg/validator/git_test.go | 2 +- pkg/validator/outsider_finder.go | 29 ++++++++---- pkg/validator/outsider_finder_test.go | 66 ++++++++++++++++++++------- pkg/validator/scope_parser_test.go | 2 +- pkg/validator/validator.go | 7 ++- pkg/validator/validator_test.go | 2 +- 15 files changed, 193 insertions(+), 63 deletions(-) create mode 100644 docs/schema/config.v3.json diff --git a/README.md b/README.md index 45a4490..314f4d5 100644 --- a/README.md +++ b/README.md @@ -95,17 +95,27 @@ commitlint-scope init ### Overview ```yaml -#$schema: https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.json +#$schema: https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.v3.json # Scope parsing customization. Not required, if you follow common conventional header. In example: 'type!(scope): subject' #scopeRegex: ^[a-z]+(?:\((?P[^)]+)\))?!?:\s -# Patterns map: each key is a scope name, value is a list of glob patterns that match files belonging to that scope. +# Patterns list: each item specifies a list of scopes and the corresponding file glob patterns. patterns: - "auth": [ "services/auth/**" ] - "migrations": [ "database/migrations/*.sql" ] - "frontend": [ "**/assets/**", "**/frontend/**" ] - "docs": [ "**/*.md" ] + - scopes: ["auth"] + files: ["services/auth/**"] + + - scopes: ["migrations", "sql"] + files: ["database/migrations/*.sql"] + + - scopes: ["frontend", "assets"] + files: ["**/assets/**", "**/frontend/**"] + + - scopes: ["docs", "md"] + files: ["**/*.md"] + + - scopes: ["some.dot.scope", "any-anotherscope"] + files: ["**/rail.v1.json"] ``` ## Zero Configuration @@ -191,7 +201,7 @@ pipelines: ## JSON schema -https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.json +https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.v3.json ## License diff --git a/cmd/commands/init.go b/cmd/commands/init.go index bc6d08a..7530401 100644 --- a/cmd/commands/init.go +++ b/cmd/commands/init.go @@ -6,7 +6,7 @@ import ( "fmt" "os" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" "github.com/urfave/cli/v3" ) @@ -16,17 +16,27 @@ var ( ErrWrite = errors.New("cannot write content") ) -const InitConfigData = `#$schema: https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.json +const InitConfigData = `#$schema: https://github.com/thumbrise/commitlint-scope/blob/main/docs/schema/config.v3.json # Scope parsing customization. Not required, if you follow common conventional header. In example: 'type!(scope): subject' #scopeRegex: ^[a-z]+(?:\((?P[^)]+)\))?!?:\s -# Patterns map: each key is a scope name, value is a list of glob patterns that match files belonging to that scope. +# Patterns list: each item specifies a list of scopes and the corresponding file glob patterns. patterns: - "auth": [ "services/auth/**" ] - "migrations": [ "database/migrations/*.sql" ] - "frontend": [ "**/assets/**", "**/frontend/**" ] - "docs": [ "**/*.md" ] + - scopes: ["auth"] + files: ["services/auth/**"] + + - scopes: ["migrations", "sql"] + files: ["database/migrations/*.sql"] + + - scopes: ["frontend", "assets"] + files: ["**/assets/**", "**/frontend/**"] + + - scopes: ["docs", "md"] + files: ["**/*.md"] + + - scopes: ["some.dot.scope", "any-anotherscope"] + files: ["**/rail.v1.json"] ` const ( diff --git a/cmd/commands/run.go b/cmd/commands/run.go index 8b158c4..b2505d7 100644 --- a/cmd/commands/run.go +++ b/cmd/commands/run.go @@ -9,8 +9,8 @@ import ( "os" "github.com/fatih/color" - "github.com/thumbrise/commitlint-scope/v2/cmd/errs" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/cmd/errs" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" "github.com/urfave/cli/v3" ) diff --git a/cmd/root.go b/cmd/root.go index 83b2f3f..22b5eac 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -7,7 +7,7 @@ import ( "os" "github.com/fatih/color" - "github.com/thumbrise/commitlint-scope/v2/cmd/commands" + "github.com/thumbrise/commitlint-scope/v3/cmd/commands" "github.com/urfave/cli/v3" ) diff --git a/docs/schema/config.v3.json b/docs/schema/config.v3.json new file mode 100644 index 0000000..ad094ed --- /dev/null +++ b/docs/schema/config.v3.json @@ -0,0 +1,44 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "commitlint-scope configuration", + "description": "Configuration for commitlint-scope - a linter that checks if declared commit scopes match the changed files.", + "type": "object", + "properties": { + "scopeRegex": { + "description": "Regular expression used to extract the scope from a conventional commit header. It must contain a named group 'scope'. If omitted, a sensible default is used. https://github.com/google/re2/wiki/Syntax", + "type": "string", + "format": "regex", + "pattern": "\\(\\?P" + }, + "patterns": { + "description": "List of pattern rules. Each rule maps a list of scope names to a list of glob patterns.", + "type": "array", + "items": { + "type": "object", + "properties": { + "scopes": { + "description": "List of scope names that share these glob patterns.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + }, + "files": { + "description": "List of glob patterns that match files belonging to the listed scopes. Patterns support globstars (**) and standard wildcards.", + "type": "array", + "items": { + "type": "string" + }, + "minItems": 1, + "uniqueItems": true + } + }, + "required": ["scopes", "files"], + "additionalProperties": false + } + } + }, + "additionalProperties": false +} diff --git a/go.mod b/go.mod index 2654a35..18e1f32 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/thumbrise/commitlint-scope/v2 +module github.com/thumbrise/commitlint-scope/v3 go 1.26.1 diff --git a/main.go b/main.go index a6fe1a7..75388c5 100644 --- a/main.go +++ b/main.go @@ -5,7 +5,7 @@ import ( "fmt" "os" - "github.com/thumbrise/commitlint-scope/v2/cmd" + "github.com/thumbrise/commitlint-scope/v3/cmd" ) var ( diff --git a/pkg/validator/config.go b/pkg/validator/config.go index 1cb60b8..a7cce16 100644 --- a/pkg/validator/config.go +++ b/pkg/validator/config.go @@ -12,9 +12,14 @@ import ( const ConfigName = ".commitlint-scope" +type PatternItem struct { + Scopes []string `mapstructure:"scopes"` + Files []string `mapstructure:"files"` +} + type Config struct { - ScopeRegex *regexp.Regexp `mapstructure:"scopeRegex"` - Patterns map[string][]string `mapstructure:"patterns"` + ScopeRegex *regexp.Regexp `mapstructure:"scopeRegex"` + Patterns []PatternItem `mapstructure:"patterns"` } var ErrConfigRead = errors.New("error reading config") diff --git a/pkg/validator/config_test.go b/pkg/validator/config_test.go index c7abc44..36ba475 100644 --- a/pkg/validator/config_test.go +++ b/pkg/validator/config_test.go @@ -7,7 +7,7 @@ import ( "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" ) func TestLoadConfig(t *testing.T) { @@ -18,7 +18,7 @@ func TestLoadConfig(t *testing.T) { yaml string wantRegexNil bool wantRegex string - wantPatterns map[string][]string + wantPatterns []validator.PatternItem wantErr error }{ { @@ -28,15 +28,15 @@ func TestLoadConfig(t *testing.T) { { name: "patterns with default regex", yaml: `patterns: - api: - - api/* - core: - - core/** + - scopes: ["api"] + files: ["api/*"] + - scopes: ["core"] + files: ["core/**"] `, wantRegex: defaultRegexStr, - wantPatterns: map[string][]string{ - "api": {"api/*"}, - "core": {"core/**"}, + wantPatterns: []validator.PatternItem{ + {Scopes: []string{"api"}, Files: []string{"api/*"}}, + {Scopes: []string{"core"}, Files: []string{"core/**"}}, }, }, { @@ -49,12 +49,23 @@ func TestLoadConfig(t *testing.T) { name: "both patterns and custom scopeRegex", yaml: `scopeRegex: '^(feat|fix):' patterns: - api: - - api/* + - scopes: ["api"] + files: ["api/*"] `, wantRegex: `^(feat|fix):`, - wantPatterns: map[string][]string{ - "api": {"api/*"}, + wantPatterns: []validator.PatternItem{ + {Scopes: []string{"api"}, Files: []string{"api/*"}}, + }, + }, + { + name: "patterns with dots inside scopes", + yaml: `patterns: + - scopes: ["rail.v1.json"] + files: ["**/rail.v1.json"] +`, + wantRegex: defaultRegexStr, + wantPatterns: []validator.PatternItem{ + {Scopes: []string{"rail.v1.json"}, Files: []string{"**/rail.v1.json"}}, }, }, { diff --git a/pkg/validator/git_test.go b/pkg/validator/git_test.go index 8293f12..ffea2c6 100644 --- a/pkg/validator/git_test.go +++ b/pkg/validator/git_test.go @@ -8,7 +8,7 @@ import ( "strings" "testing" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" ) func gitCall(t *testing.T, dir string, args ...string) string { diff --git a/pkg/validator/outsider_finder.go b/pkg/validator/outsider_finder.go index ca3ace9..7b8b005 100644 --- a/pkg/validator/outsider_finder.go +++ b/pkg/validator/outsider_finder.go @@ -15,13 +15,21 @@ type DefaultOutsiderFinder struct { scopesToPatternsGlob map[string][]glob.Glob } +// OutsiderFinderPattern matches the new configuration structure. +type OutsiderFinderPattern struct { + Scopes []string + Files []string +} + // NewDefaultOutsiderFinder creates a new DefaultOutsiderFinder. -// scopesToPatterns is a map from scope name to a list of glob pattern strings. -func NewDefaultOutsiderFinder(scopesToPatterns map[string][]string) (*DefaultOutsiderFinder, error) { - scopesToPatternsGlob := make(map[string][]glob.Glob, len(scopesToPatterns)) - for scope, patterns := range scopesToPatterns { - globs := make([]glob.Glob, 0, len(patterns)) - for _, pattern := range patterns { +// It accepts a slice of OutsiderFinderPattern and flattens it into efficient lookup maps. +func NewDefaultOutsiderFinder(patterns []OutsiderFinderPattern) (*DefaultOutsiderFinder, error) { + scopesToPatternsHuman := make(map[string][]string) + scopesToPatternsGlob := make(map[string][]glob.Glob) + + for _, item := range patterns { + globs := make([]glob.Glob, 0, len(item.Files)) + for _, pattern := range item.Files { g, err := glob.Compile(pattern, '/') if err != nil { return nil, fmt.Errorf("%w: %q: %w", ErrInvalidGlobPattern, pattern, err) @@ -30,11 +38,14 @@ func NewDefaultOutsiderFinder(scopesToPatterns map[string][]string) (*DefaultOut globs = append(globs, g) } - scopesToPatternsGlob[scope] = globs + for _, scope := range item.Scopes { + scopesToPatternsHuman[scope] = append(scopesToPatternsHuman[scope], item.Files...) + scopesToPatternsGlob[scope] = append(scopesToPatternsGlob[scope], globs...) + } } return &DefaultOutsiderFinder{ - scopesToPatternsHuman: scopesToPatterns, + scopesToPatternsHuman: scopesToPatternsHuman, scopesToPatternsGlob: scopesToPatternsGlob, }, nil } @@ -47,7 +58,7 @@ func (f *DefaultOutsiderFinder) Find(scope string, files []string) []Outsider { // zero config if len(globFilePatterns) == 0 { - defaultPattern := scope + "/**" + defaultPattern := glob.QuoteMeta(scope) + "/**" g, err := glob.Compile(defaultPattern, '/') if err != nil { diff --git a/pkg/validator/outsider_finder_test.go b/pkg/validator/outsider_finder_test.go index 4be30aa..ab40473 100644 --- a/pkg/validator/outsider_finder_test.go +++ b/pkg/validator/outsider_finder_test.go @@ -5,21 +5,24 @@ import ( "slices" "testing" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" ) func TestDefaultOutsiderFinder_Find(t *testing.T) { tests := []struct { name string - patterns map[string][]string + patterns []validator.OutsiderFinderPattern scope string files []string wantOutsiders []string }{ { name: "no explicit patterns - uses scope/** as default", - patterns: map[string][]string{ - "api": {}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"api"}, + Files: []string{}, + }, }, scope: "api", files: []string{"api/handler.go", "api/helper.go", "core/other.go"}, @@ -27,14 +30,18 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "no explicit patterns, even no configured scopes - uses scope/** as default", + patterns: nil, scope: "api", files: []string{"api/handler.go", "api/helper.go", "core/other.go"}, wantOutsiders: []string{"core/other.go"}, }, { name: "exact match with wildcard", - patterns: map[string][]string{ - "api": {"api/*"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"api"}, + Files: []string{"api/*"}, + }, }, scope: "api", files: []string{"api/handler.go", "api/helper.go", "core/other.go"}, @@ -42,8 +49,11 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "globstar matches nested files", - patterns: map[string][]string{ - "api": {"api/**"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"api"}, + Files: []string{"api/**"}, + }, }, scope: "api", files: []string{"api/handler.go", "api/sub/helper.go", "core/other.go"}, @@ -51,8 +61,11 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "wildcard matches dotfiles (like dot:true)", - patterns: map[string][]string{ - "env": {"*"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"env"}, + Files: []string{"*"}, + }, }, scope: "env", files: []string{".env", "config.go"}, @@ -60,8 +73,11 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "explicit dot pattern still works", - patterns: map[string][]string{ - "env": {".*", "*.go"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"env"}, + Files: []string{".*", "*.go"}, + }, }, scope: "env", files: []string{".env", "config.go", ".gitignore"}, @@ -69,8 +85,11 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "globstar matches dot directories", - patterns: map[string][]string{ - "api": {"api/**"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"api"}, + Files: []string{"api/**"}, + }, }, scope: "api", files: []string{"api/.internal/secret.go", "api/outer/file.go"}, @@ -78,13 +97,28 @@ func TestDefaultOutsiderFinder_Find(t *testing.T) { }, { name: "complex patterns with multiple stars", - patterns: map[string][]string{ - "db": {"db/migrations/*.sql", "db/schema/**"}, + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"db"}, + Files: []string{"db/migrations/*.sql", "db/schema/**"}, + }, }, scope: "db", files: []string{"db/migrations/001_init.sql", "db/schema/latest.json", "db/docs/readme.md"}, wantOutsiders: []string{"db/docs/readme.md"}, }, + { + name: "multiple scopes sharing same patterns", + patterns: []validator.OutsiderFinderPattern{ + { + Scopes: []string{"api", "v1.json"}, + Files: []string{"**/rail.v1.json"}, + }, + }, + scope: "v1.json", + files: []string{"internal/rail.v1.json", "external/rail.v1.json", "core/other.go"}, + wantOutsiders: []string{"core/other.go"}, + }, } for _, tt := range tests { diff --git a/pkg/validator/scope_parser_test.go b/pkg/validator/scope_parser_test.go index 930dd2e..d4cff19 100644 --- a/pkg/validator/scope_parser_test.go +++ b/pkg/validator/scope_parser_test.go @@ -4,7 +4,7 @@ import ( "regexp" "testing" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" ) func TestDefaultScopeParser_Parse(t *testing.T) { diff --git a/pkg/validator/validator.go b/pkg/validator/validator.go index c2f8405..ccd10c8 100644 --- a/pkg/validator/validator.go +++ b/pkg/validator/validator.go @@ -63,7 +63,12 @@ func NewValidator(cfg Config, options Options) (*Validator, error) { if outsiderFinder == nil { var err error - outsiderFinder, err = NewDefaultOutsiderFinder(cfg.Patterns) + ofPatterns := make([]OutsiderFinderPattern, len(cfg.Patterns)) + for i, cfgPattern := range cfg.Patterns { + ofPatterns[i] = OutsiderFinderPattern(cfgPattern) + } + + outsiderFinder, err = NewDefaultOutsiderFinder(ofPatterns) if err != nil { return nil, err } diff --git a/pkg/validator/validator_test.go b/pkg/validator/validator_test.go index d568ed1..aa58401 100644 --- a/pkg/validator/validator_test.go +++ b/pkg/validator/validator_test.go @@ -7,7 +7,7 @@ import ( "testing" "github.com/stretchr/testify/mock" - "github.com/thumbrise/commitlint-scope/v2/pkg/validator" + "github.com/thumbrise/commitlint-scope/v3/pkg/validator" ) type TestableCommit struct {