feat(golangci): add module plugin integration
- commit
- f23cdad5a73aeec150fb1fc289c44520d1d8646d
- author
- brice · · 2026-05-30 05:01 UTC
- parent
- 0796ef4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
12 files · +323 -56
M README.md +10 -4
| @@ -59,10 +59,16 @@ hexcheck --config .hexcheck.yaml ./... | |||
| 59 | 59 | ||
| 60 | 60 | ## golangci-lint module plugin | |
| 61 | 61 | ||
| 62 | - | Build a custom golangci-lint binary: | |
| 62 | + | Build a custom golangci-lint binary using the module plugin system: | |
| 63 | + | ||
| 64 | + | ```bash | |
| 65 | + | golangci-lint custom -c examples/custom-gcl.yml | |
| 66 | + | ``` | |
| 67 | + | ||
| 68 | + | Example builder config: | |
| 63 | 69 | ||
| 64 | 70 | ```yaml | |
| 65 | - | # .custom-gcl.yml | |
| 71 | + | # examples/custom-gcl.yml | |
| 66 | 72 | version: v2.12.2 | |
| 67 | 73 | name: hex-golangci-lint | |
| 68 | 74 | destination: ./bin | |
| @@ -72,10 +78,10 @@ plugins: | |||
| 72 | 78 | version: v0.1.0 | |
| 73 | 79 | ``` | |
| 74 | 80 | ||
| 75 | - | Enable it: | |
| 81 | + | Enable it in a project: | |
| 76 | 82 | ||
| 77 | 83 | ```yaml | |
| 78 | - | # .golangci.yml | |
| 84 | + | # examples/golangci.yml | |
| 79 | 85 | version: "2" | |
| 80 | 86 | linters: | |
| 81 | 87 | enable: | |
| @@ -59,10 +59,16 @@ hexcheck --config .hexcheck.yaml ./... | |||||
| 59 | 59 | ||||
| 60 | ## golangci-lint module plugin | 60 | ## golangci-lint module plugin | ||
| 61 | 61 | ||||
| 62 | - | Build a custom golangci-lint binary: | 62 | + | Build a custom golangci-lint binary using the module plugin system: |
| 63 | + | ||||
| 64 | + | ```bash | |||
| 65 | + | golangci-lint custom -c examples/custom-gcl.yml | |||
| 66 | + | ``` | |||
| 67 | + | ||||
| 68 | + | Example builder config: | |||
| 63 | 69 | ||||
| 64 | ```yaml | 70 | ```yaml | ||
| 65 | - | # .custom-gcl.yml | 71 | + | # examples/custom-gcl.yml |
| 66 | version: v2.12.2 | 72 | version: v2.12.2 | ||
| 67 | name: hex-golangci-lint | 73 | name: hex-golangci-lint | ||
| 68 | destination: ./bin | 74 | destination: ./bin | ||
| @@ -72,10 +78,10 @@ plugins: | |||||
| 72 | version: v0.1.0 | 78 | version: v0.1.0 | ||
| 73 | ``` | 79 | ``` | ||
| 74 | 80 | ||||
| 75 | - | Enable it: | 81 | + | Enable it in a project: |
| 76 | 82 | ||||
| 77 | ```yaml | 83 | ```yaml | ||
| 78 | - | # .golangci.yml | 84 | + | # examples/golangci.yml |
| 79 | version: "2" | 85 | version: "2" | ||
| 80 | linters: | 86 | linters: | ||
| 81 | enable: | 87 | enable: | ||
M analyzer/analyzer_test.go +28 -13
| @@ -7,20 +7,32 @@ import ( | |||
| 7 | 7 | "golang.org/x/tools/go/analysis/analysistest" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestAnalyzerBoundaryImports(t *testing.T) { | |
| 11 | - | cfg := testConfig() | |
| 12 | - | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), | |
| 13 | - | "example.com/project/internal/domain", | |
| 14 | - | "example.com/project/internal/application/usecase", | |
| 15 | - | "example.com/project/internal/infrastructure/http", | |
| 16 | - | ) | |
| 17 | - | } | |
| 10 | + | func TestAnalyzer(t *testing.T) { | |
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | patterns []string | |
| 14 | + | }{ | |
| 15 | + | { | |
| 16 | + | name: "boundary imports", | |
| 17 | + | patterns: []string{ | |
| 18 | + | "example.com/project/internal/domain", | |
| 19 | + | "example.com/project/internal/application/usecase", | |
| 20 | + | "example.com/project/internal/infrastructure/http", | |
| 21 | + | }, | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "type leaks", | |
| 25 | + | patterns: []string{ | |
| 26 | + | "example.com/project/internal/application/port", | |
| 27 | + | }, | |
| 28 | + | }, | |
| 29 | + | } | |
| 18 | 30 | ||
| 19 | - | func TestAnalyzerTypeLeaks(t *testing.T) { | |
| 20 | - | cfg := testConfig() | |
| 21 | - | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), | |
| 22 | - | "example.com/project/internal/application/port", | |
| 23 | - | ) | |
| 31 | + | for _, tt := range tests { | |
| 32 | + | t.Run(tt.name, func(t *testing.T) { | |
| 33 | + | analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), tt.patterns...) | |
| 34 | + | }) | |
| 35 | + | } | |
| 24 | 36 | } | |
| 25 | 37 | ||
| 26 | 38 | func testConfig() *config.Config { | |
| @@ -37,6 +49,9 @@ func testConfig() *config.Config { | |||
| 37 | 49 | ExternalTypes: config.ExternalTypes{ | |
| 38 | 50 | FrameworkPackages: []string{"example.com/framework"}, | |
| 39 | 51 | }, | |
| 52 | + | Allow: []config.Allow{ | |
| 53 | + | {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"}, | |
| 54 | + | }, | |
| 40 | 55 | } | |
| 41 | 56 | if err := cfg.Validate(); err != nil { | |
| 42 | 57 | panic(err) | |
| @@ -7,20 +7,32 @@ import ( | |||||
| 7 | "golang.org/x/tools/go/analysis/analysistest" | 7 | "golang.org/x/tools/go/analysis/analysistest" | ||
| 8 | ) | 8 | ) | ||
| 9 | 9 | ||||
| 10 | - | func TestAnalyzerBoundaryImports(t *testing.T) { | 10 | + | func TestAnalyzer(t *testing.T) { |
| 11 | - | cfg := testConfig() | 11 | + | tests := []struct { |
| 12 | - | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), | 12 | + | name string |
| 13 | - | "example.com/project/internal/domain", | 13 | + | patterns []string |
| 14 | - | "example.com/project/internal/application/usecase", | 14 | + | }{ |
| 15 | - | "example.com/project/internal/infrastructure/http", | 15 | + | { |
| 16 | - | ) | 16 | + | name: "boundary imports", |
| 17 | - | } | 17 | + | patterns: []string{ |
| 18 | + | "example.com/project/internal/domain", | |||
| 19 | + | "example.com/project/internal/application/usecase", | |||
| 20 | + | "example.com/project/internal/infrastructure/http", | |||
| 21 | + | }, | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | name: "type leaks", | |||
| 25 | + | patterns: []string{ | |||
| 26 | + | "example.com/project/internal/application/port", | |||
| 27 | + | }, | |||
| 28 | + | }, | |||
| 29 | + | } | |||
| 18 | 30 | ||||
| 19 | - | func TestAnalyzerTypeLeaks(t *testing.T) { | 31 | + | for _, tt := range tests { |
| 20 | - | cfg := testConfig() | 32 | + | t.Run(tt.name, func(t *testing.T) { |
| 21 | - | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), | 33 | + | analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), tt.patterns...) |
| 22 | - | "example.com/project/internal/application/port", | 34 | + | }) |
| 23 | - | ) | 35 | + | } |
| 24 | } | 36 | } | ||
| 25 | 37 | ||||
| 26 | func testConfig() *config.Config { | 38 | func testConfig() *config.Config { | ||
| @@ -37,6 +49,9 @@ func testConfig() *config.Config { | |||||
| 37 | ExternalTypes: config.ExternalTypes{ | 49 | ExternalTypes: config.ExternalTypes{ | ||
| 38 | FrameworkPackages: []string{"example.com/framework"}, | 50 | FrameworkPackages: []string{"example.com/framework"}, | ||
| 39 | }, | 51 | }, | ||
| 52 | + | Allow: []config.Allow{ | |||
| 53 | + | {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"}, | |||
| 54 | + | }, | |||
| 40 | } | 55 | } | ||
| 41 | if err := cfg.Validate(); err != nil { | 56 | if err := cfg.Validate(); err != nil { | ||
| 42 | panic(err) | 57 | panic(err) | ||
M analyzer/mocks.go +47 -2
| @@ -2,6 +2,7 @@ package analyzer | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "go/ast" | |
| 5 | + | "go/types" | |
| 5 | 6 | "strconv" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| @@ -22,6 +23,11 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||
| 22 | 23 | } | |
| 23 | 24 | } | |
| 24 | 25 | ||
| 26 | + | portInterfaces := r.importedPortInterfaces(file) | |
| 27 | + | if len(portInterfaces) == 0 { | |
| 28 | + | return | |
| 29 | + | } | |
| 30 | + | ||
| 25 | 31 | ast.Inspect(file, func(n ast.Node) bool { | |
| 26 | 32 | ts, ok := n.(*ast.TypeSpec) | |
| 27 | 33 | if !ok { | |
| @@ -29,9 +35,48 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||
| 29 | 35 | } | |
| 30 | 36 | ||
| 31 | 37 | name := strings.ToLower(ts.Name.Name) | |
| 32 | - | if strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") { | |
| 33 | - | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s declared in test file; prefer generated mocks when a port interface is available", ts.Name.Name) | |
| 38 | + | if !isTestDoubleName(name) { | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName) | |
| 42 | + | if !ok || obj.Type() == nil { | |
| 43 | + | return true | |
| 44 | + | } | |
| 45 | + | for _, iface := range portInterfaces { | |
| 46 | + | if types.Implements(obj.Type(), iface) || types.Implements(types.NewPointer(obj.Type()), iface) { | |
| 47 | + | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s; prefer generated mocks when available", ts.Name.Name, iface.String()) | |
| 48 | + | break | |
| 49 | + | } | |
| 34 | 50 | } | |
| 35 | 51 | return true | |
| 36 | 52 | }) | |
| 37 | 53 | } | |
| 54 | + | ||
| 55 | + | func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |
| 56 | + | var out []*types.Interface | |
| 57 | + | for _, imp := range file.Imports { | |
| 58 | + | pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName) | |
| 59 | + | if !ok || pkgName.Imported() == nil { | |
| 60 | + | continue | |
| 61 | + | } | |
| 62 | + | if match, ok := r.cfg.ComponentForPath(r.relImportPath(pkgName.Imported().Path())); !ok || match.Role != config.RolePorts { | |
| 63 | + | continue | |
| 64 | + | } | |
| 65 | + | scope := pkgName.Imported().Scope() | |
| 66 | + | for _, name := range scope.Names() { | |
| 67 | + | obj, ok := scope.Lookup(name).(*types.TypeName) | |
| 68 | + | if !ok { | |
| 69 | + | continue | |
| 70 | + | } | |
| 71 | + | iface, ok := obj.Type().Underlying().(*types.Interface) | |
| 72 | + | if ok { | |
| 73 | + | out = append(out, iface.Complete()) | |
| 74 | + | } | |
| 75 | + | } | |
| 76 | + | } | |
| 77 | + | return out | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | func isTestDoubleName(name string) bool { | |
| 81 | + | return strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") | |
| 82 | + | } | |
| @@ -2,6 +2,7 @@ package analyzer | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "go/ast" | 4 | "go/ast" | ||
| 5 | + | "go/types" | |||
| 5 | "strconv" | 6 | "strconv" | ||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| @@ -22,6 +23,11 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||||
| 22 | } | 23 | } | ||
| 23 | } | 24 | } | ||
| 24 | 25 | ||||
| 26 | + | portInterfaces := r.importedPortInterfaces(file) | |||
| 27 | + | if len(portInterfaces) == 0 { | |||
| 28 | + | return | |||
| 29 | + | } | |||
| 30 | + | ||||
| 25 | ast.Inspect(file, func(n ast.Node) bool { | 31 | ast.Inspect(file, func(n ast.Node) bool { | ||
| 26 | ts, ok := n.(*ast.TypeSpec) | 32 | ts, ok := n.(*ast.TypeSpec) | ||
| 27 | if !ok { | 33 | if !ok { | ||
| @@ -29,9 +35,48 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||||
| 29 | } | 35 | } | ||
| 30 | 36 | ||||
| 31 | name := strings.ToLower(ts.Name.Name) | 37 | name := strings.ToLower(ts.Name.Name) | ||
| 32 | - | if strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") { | 38 | + | if !isTestDoubleName(name) { |
| 33 | - | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s declared in test file; prefer generated mocks when a port interface is available", ts.Name.Name) | 39 | + | return true |
| 40 | + | } | |||
| 41 | + | obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName) | |||
| 42 | + | if !ok || obj.Type() == nil { | |||
| 43 | + | return true | |||
| 44 | + | } | |||
| 45 | + | for _, iface := range portInterfaces { | |||
| 46 | + | if types.Implements(obj.Type(), iface) || types.Implements(types.NewPointer(obj.Type()), iface) { | |||
| 47 | + | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s; prefer generated mocks when available", ts.Name.Name, iface.String()) | |||
| 48 | + | break | |||
| 49 | + | } | |||
| 34 | } | 50 | } | ||
| 35 | return true | 51 | return true | ||
| 36 | }) | 52 | }) | ||
| 37 | } | 53 | } | ||
| 54 | + | ||||
| 55 | + | func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |||
| 56 | + | var out []*types.Interface | |||
| 57 | + | for _, imp := range file.Imports { | |||
| 58 | + | pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName) | |||
| 59 | + | if !ok || pkgName.Imported() == nil { | |||
| 60 | + | continue | |||
| 61 | + | } | |||
| 62 | + | if match, ok := r.cfg.ComponentForPath(r.relImportPath(pkgName.Imported().Path())); !ok || match.Role != config.RolePorts { | |||
| 63 | + | continue | |||
| 64 | + | } | |||
| 65 | + | scope := pkgName.Imported().Scope() | |||
| 66 | + | for _, name := range scope.Names() { | |||
| 67 | + | obj, ok := scope.Lookup(name).(*types.TypeName) | |||
| 68 | + | if !ok { | |||
| 69 | + | continue | |||
| 70 | + | } | |||
| 71 | + | iface, ok := obj.Type().Underlying().(*types.Interface) | |||
| 72 | + | if ok { | |||
| 73 | + | out = append(out, iface.Complete()) | |||
| 74 | + | } | |||
| 75 | + | } | |||
| 76 | + | } | |||
| 77 | + | return out | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | func isTestDoubleName(name string) bool { | |||
| 81 | + | return strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") | |||
| 82 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/application/usecase/usecase_test.go +12 -0
| @@ -0,0 +1,12 @@ | |||
| 1 | + | package usecase | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "example.com/project/internal/application/port" | |
| 5 | + | "example.com/project/internal/infrastructure/sql" // want "prefer-generated-mocks" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type fakeUserRepository struct{} // want "no-local-fakes-for-ports" | |
| 9 | + | ||
| 10 | + | func (fakeUserRepository) Find() (*sql.Row, error) { return nil, nil } | |
| 11 | + | ||
| 12 | + | var _ port.UserRepository = fakeUserRepository{} | |
| @@ -0,0 +1,12 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "example.com/project/internal/application/port" | |||
| 5 | + | "example.com/project/internal/infrastructure/sql" // want "prefer-generated-mocks" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type fakeUserRepository struct{} // want "no-local-fakes-for-ports" | |||
| 9 | + | ||||
| 10 | + | func (fakeUserRepository) Find() (*sql.Row, error) { return nil, nil } | |||
| 11 | + | ||||
| 12 | + | var _ port.UserRepository = fakeUserRepository{} | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/logic.go +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | func ValidateOrder(total int, active bool) bool { // want "suspicious-business-logic-in-adapter" | |
| 4 | + | if total <= 0 { | |
| 5 | + | return false | |
| 6 | + | } | |
| 7 | + | if !active { | |
| 8 | + | return false | |
| 9 | + | } | |
| 10 | + | return true | |
| 11 | + | } | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | func ValidateOrder(total int, active bool) bool { // want "suspicious-business-logic-in-adapter" | |||
| 4 | + | if total <= 0 { | |||
| 5 | + | return false | |||
| 6 | + | } | |||
| 7 | + | if !active { | |||
| 8 | + | return false | |||
| 9 | + | } | |||
| 10 | + | return true | |||
| 11 | + | } | |||
M config/config_test.go +72 -37
| @@ -7,9 +7,15 @@ import ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | func TestLoad(t *testing.T) { | |
| 10 | - | dir := t.TempDir() | |
| 11 | - | path := filepath.Join(dir, ".hexcheck.yaml") | |
| 12 | - | data := []byte(`version: 1 | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | yaml string | |
| 13 | + | wantErr bool | |
| 14 | + | check func(t *testing.T, cfg *Config) | |
| 15 | + | }{ | |
| 16 | + | { | |
| 17 | + | name: "valid config", | |
| 18 | + | yaml: `version: 1 | |
| 13 | 19 | components: | |
| 14 | 20 | core: | |
| 15 | 21 | paths: [internal/domain/**] | |
| @@ -23,25 +29,56 @@ allow: | |||
| 23 | 29 | - rule: no-local-fakes-for-ports | |
| 24 | 30 | path: internal/foo_test.go | |
| 25 | 31 | reason: stateful fake | |
| 26 | - | `) | |
| 27 | - | if err := os.WriteFile(path, data, 0o600); err != nil { | |
| 28 | - | t.Fatal(err) | |
| 29 | - | } | |
| 30 | - | cfg, err := Load(path) | |
| 31 | - | if err != nil { | |
| 32 | - | t.Fatal(err) | |
| 33 | - | } | |
| 34 | - | if cfg.Version != 1 { | |
| 35 | - | t.Fatalf("Version = %d", cfg.Version) | |
| 36 | - | } | |
| 37 | - | if cfg.Components["core"].Role != RoleCore { | |
| 38 | - | t.Fatalf("core role = %q", cfg.Components["core"].Role) | |
| 39 | - | } | |
| 40 | - | if cfg.Severity("no-adapter-imports-in-core") != SeverityError { | |
| 41 | - | t.Fatalf("severity not parsed") | |
| 32 | + | `, | |
| 33 | + | check: func(t *testing.T, cfg *Config) { | |
| 34 | + | t.Helper() | |
| 35 | + | if cfg.Version != 1 { | |
| 36 | + | t.Fatalf("Version = %d", cfg.Version) | |
| 37 | + | } | |
| 38 | + | if cfg.Components["core"].Role != RoleCore { | |
| 39 | + | t.Fatalf("core role = %q", cfg.Components["core"].Role) | |
| 40 | + | } | |
| 41 | + | if cfg.Severity("no-adapter-imports-in-core") != SeverityError { | |
| 42 | + | t.Fatalf("severity not parsed") | |
| 43 | + | } | |
| 44 | + | if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") { | |
| 45 | + | t.Fatalf("allow entry not matched") | |
| 46 | + | } | |
| 47 | + | }, | |
| 48 | + | }, | |
| 49 | + | { | |
| 50 | + | name: "invalid role", | |
| 51 | + | yaml: `version: 1 | |
| 52 | + | components: | |
| 53 | + | bad: | |
| 54 | + | paths: [internal/**] | |
| 55 | + | role: wat | |
| 56 | + | `, | |
| 57 | + | wantErr: true, | |
| 58 | + | }, | |
| 42 | 59 | } | |
| 43 | - | if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") { | |
| 44 | - | t.Fatalf("allow entry not matched") | |
| 60 | + | ||
| 61 | + | for _, tt := range tests { | |
| 62 | + | t.Run(tt.name, func(t *testing.T) { | |
| 63 | + | dir := t.TempDir() | |
| 64 | + | path := filepath.Join(dir, ".hexcheck.yaml") | |
| 65 | + | if err := os.WriteFile(path, []byte(tt.yaml), 0o600); err != nil { | |
| 66 | + | t.Fatal(err) | |
| 67 | + | } | |
| 68 | + | cfg, err := Load(path) | |
| 69 | + | if tt.wantErr { | |
| 70 | + | if err == nil { | |
| 71 | + | t.Fatal("expected error") | |
| 72 | + | } | |
| 73 | + | return | |
| 74 | + | } | |
| 75 | + | if err != nil { | |
| 76 | + | t.Fatal(err) | |
| 77 | + | } | |
| 78 | + | if tt.check != nil { | |
| 79 | + | tt.check(t, cfg) | |
| 80 | + | } | |
| 81 | + | }) | |
| 45 | 82 | } | |
| 46 | 83 | } | |
| 47 | 84 | ||
| @@ -53,22 +90,20 @@ func TestComponentForPathPrecedence(t *testing.T) { | |||
| 53 | 90 | }} | |
| 54 | 91 | cfg.applyDefaults() | |
| 55 | 92 | ||
| 56 | - | match, ok := cfg.ComponentForPath("internal/infrastructure/postgres/repo.go") | |
| 57 | - | if !ok || match.Role != RoleAdapter { | |
| 58 | - | t.Fatalf("match = %#v, %v", match, ok) | |
| 93 | + | tests := []struct { | |
| 94 | + | name string | |
| 95 | + | path string | |
| 96 | + | want Role | |
| 97 | + | }{ | |
| 98 | + | {name: "specific adapter beats broad core", path: "internal/infrastructure/postgres/repo.go", want: RoleAdapter}, | |
| 99 | + | {name: "ignore beats adapter", path: "internal/infrastructure/generated/foo.go", want: RoleIgnore}, | |
| 59 | 100 | } | |
| 60 | - | match, ok = cfg.ComponentForPath("internal/infrastructure/generated/foo.go") | |
| 61 | - | if !ok || match.Role != RoleIgnore { | |
| 62 | - | t.Fatalf("ignore match = %#v, %v", match, ok) | |
| 63 | - | } | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | func TestValidateRejectsBadRole(t *testing.T) { | |
| 67 | - | cfg := &Config{Version: 1, Components: map[string]Component{ | |
| 68 | - | "bad": {Role: "wat", Paths: []string{"internal/**"}}, | |
| 69 | - | }} | |
| 70 | - | cfg.applyDefaults() | |
| 71 | - | if err := cfg.Validate(); err == nil { | |
| 72 | - | t.Fatal("expected validation error") | |
| 101 | + | for _, tt := range tests { | |
| 102 | + | t.Run(tt.name, func(t *testing.T) { | |
| 103 | + | match, ok := cfg.ComponentForPath(tt.path) | |
| 104 | + | if !ok || match.Role != tt.want { | |
| 105 | + | t.Fatalf("match = %#v, %v; want role %q", match, ok, tt.want) | |
| 106 | + | } | |
| 107 | + | }) | |
| 73 | 108 | } | |
| 74 | 109 | } | |
| @@ -7,9 +7,15 @@ import ( | |||||
| 7 | ) | 7 | ) | ||
| 8 | 8 | ||||
| 9 | func TestLoad(t *testing.T) { | 9 | func TestLoad(t *testing.T) { | ||
| 10 | - | dir := t.TempDir() | 10 | + | tests := []struct { |
| 11 | - | path := filepath.Join(dir, ".hexcheck.yaml") | 11 | + | name string |
| 12 | - | data := []byte(`version: 1 | 12 | + | yaml string |
| 13 | + | wantErr bool | |||
| 14 | + | check func(t *testing.T, cfg *Config) | |||
| 15 | + | }{ | |||
| 16 | + | { | |||
| 17 | + | name: "valid config", | |||
| 18 | + | yaml: `version: 1 | |||
| 13 | components: | 19 | components: | ||
| 14 | core: | 20 | core: | ||
| 15 | paths: [internal/domain/**] | 21 | paths: [internal/domain/**] | ||
| @@ -23,25 +29,56 @@ allow: | |||||
| 23 | - rule: no-local-fakes-for-ports | 29 | - rule: no-local-fakes-for-ports | ||
| 24 | path: internal/foo_test.go | 30 | path: internal/foo_test.go | ||
| 25 | reason: stateful fake | 31 | reason: stateful fake | ||
| 26 | - | `) | 32 | + | `, |
| 27 | - | if err := os.WriteFile(path, data, 0o600); err != nil { | 33 | + | check: func(t *testing.T, cfg *Config) { |
| 28 | - | t.Fatal(err) | 34 | + | t.Helper() |
| 29 | - | } | 35 | + | if cfg.Version != 1 { |
| 30 | - | cfg, err := Load(path) | 36 | + | t.Fatalf("Version = %d", cfg.Version) |
| 31 | - | if err != nil { | 37 | + | } |
| 32 | - | t.Fatal(err) | 38 | + | if cfg.Components["core"].Role != RoleCore { |
| 33 | - | } | 39 | + | t.Fatalf("core role = %q", cfg.Components["core"].Role) |
| 34 | - | if cfg.Version != 1 { | 40 | + | } |
| 35 | - | t.Fatalf("Version = %d", cfg.Version) | 41 | + | if cfg.Severity("no-adapter-imports-in-core") != SeverityError { |
| 36 | - | } | 42 | + | t.Fatalf("severity not parsed") |
| 37 | - | if cfg.Components["core"].Role != RoleCore { | 43 | + | } |
| 38 | - | t.Fatalf("core role = %q", cfg.Components["core"].Role) | 44 | + | if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") { |
| 39 | - | } | 45 | + | t.Fatalf("allow entry not matched") |
| 40 | - | if cfg.Severity("no-adapter-imports-in-core") != SeverityError { | 46 | + | } |
| 41 | - | t.Fatalf("severity not parsed") | 47 | + | }, |
| 48 | + | }, | |||
| 49 | + | { | |||
| 50 | + | name: "invalid role", | |||
| 51 | + | yaml: `version: 1 | |||
| 52 | + | components: | |||
| 53 | + | bad: | |||
| 54 | + | paths: [internal/**] | |||
| 55 | + | role: wat | |||
| 56 | + | `, | |||
| 57 | + | wantErr: true, | |||
| 58 | + | }, | |||
| 42 | } | 59 | } | ||
| 43 | - | if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") { | 60 | + | |
| 44 | - | t.Fatalf("allow entry not matched") | 61 | + | for _, tt := range tests { |
| 62 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 63 | + | dir := t.TempDir() | |||
| 64 | + | path := filepath.Join(dir, ".hexcheck.yaml") | |||
| 65 | + | if err := os.WriteFile(path, []byte(tt.yaml), 0o600); err != nil { | |||
| 66 | + | t.Fatal(err) | |||
| 67 | + | } | |||
| 68 | + | cfg, err := Load(path) | |||
| 69 | + | if tt.wantErr { | |||
| 70 | + | if err == nil { | |||
| 71 | + | t.Fatal("expected error") | |||
| 72 | + | } | |||
| 73 | + | return | |||
| 74 | + | } | |||
| 75 | + | if err != nil { | |||
| 76 | + | t.Fatal(err) | |||
| 77 | + | } | |||
| 78 | + | if tt.check != nil { | |||
| 79 | + | tt.check(t, cfg) | |||
| 80 | + | } | |||
| 81 | + | }) | |||
| 45 | } | 82 | } | ||
| 46 | } | 83 | } | ||
| 47 | 84 | ||||
| @@ -53,22 +90,20 @@ func TestComponentForPathPrecedence(t *testing.T) { | |||||
| 53 | }} | 90 | }} | ||
| 54 | cfg.applyDefaults() | 91 | cfg.applyDefaults() | ||
| 55 | 92 | ||||
| 56 | - | match, ok := cfg.ComponentForPath("internal/infrastructure/postgres/repo.go") | 93 | + | tests := []struct { |
| 57 | - | if !ok || match.Role != RoleAdapter { | 94 | + | name string |
| 58 | - | t.Fatalf("match = %#v, %v", match, ok) | 95 | + | path string |
| 96 | + | want Role | |||
| 97 | + | }{ | |||
| 98 | + | {name: "specific adapter beats broad core", path: "internal/infrastructure/postgres/repo.go", want: RoleAdapter}, | |||
| 99 | + | {name: "ignore beats adapter", path: "internal/infrastructure/generated/foo.go", want: RoleIgnore}, | |||
| 59 | } | 100 | } | ||
| 60 | - | match, ok = cfg.ComponentForPath("internal/infrastructure/generated/foo.go") | 101 | + | for _, tt := range tests { |
| 61 | - | if !ok || match.Role != RoleIgnore { | 102 | + | t.Run(tt.name, func(t *testing.T) { |
| 62 | - | t.Fatalf("ignore match = %#v, %v", match, ok) | 103 | + | match, ok := cfg.ComponentForPath(tt.path) |
| 63 | - | } | 104 | + | if !ok || match.Role != tt.want { |
| 64 | - | } | 105 | + | t.Fatalf("match = %#v, %v; want role %q", match, ok, tt.want) |
| 65 | - | 106 | + | } | |
| 66 | - | func TestValidateRejectsBadRole(t *testing.T) { | 107 | + | }) |
| 67 | - | cfg := &Config{Version: 1, Components: map[string]Component{ | |||
| 68 | - | "bad": {Role: "wat", Paths: []string{"internal/**"}}, | |||
| 69 | - | }} | |||
| 70 | - | cfg.applyDefaults() | |||
| 71 | - | if err := cfg.Validate(); err == nil { | |||
| 72 | - | t.Fatal("expected validation error") | |||
| 73 | } | 108 | } | ||
| 74 | } | 109 | } | ||
A examples/custom-gcl.yml +7 -0
| @@ -0,0 +1,7 @@ | |||
| 1 | + | version: v2.12.2 | |
| 2 | + | name: hex-golangci-lint | |
| 3 | + | destination: ./bin | |
| 4 | + | plugins: | |
| 5 | + | - module: github.com/bnema/hexcheck | |
| 6 | + | import: github.com/bnema/hexcheck/golangci | |
| 7 | + | version: v0.1.0 | |
| @@ -0,0 +1,7 @@ | |||||
| 1 | + | version: v2.12.2 | |||
| 2 | + | name: hex-golangci-lint | |||
| 3 | + | destination: ./bin | |||
| 4 | + | plugins: | |||
| 5 | + | - module: github.com/bnema/hexcheck | |||
| 6 | + | import: github.com/bnema/hexcheck/golangci | |||
| 7 | + | version: v0.1.0 | |||
A examples/golangci.yml +11 -0
| @@ -0,0 +1,11 @@ | |||
| 1 | + | version: "2" | |
| 2 | + | linters: | |
| 3 | + | enable: | |
| 4 | + | - hexcheck | |
| 5 | + | settings: | |
| 6 | + | custom: | |
| 7 | + | hexcheck: | |
| 8 | + | type: module | |
| 9 | + | description: Checks hexagonal architecture boundaries. | |
| 10 | + | settings: | |
| 11 | + | config: .hexcheck.yaml | |
| @@ -0,0 +1,11 @@ | |||||
| 1 | + | version: "2" | |||
| 2 | + | linters: | |||
| 3 | + | enable: | |||
| 4 | + | - hexcheck | |||
| 5 | + | settings: | |||
| 6 | + | custom: | |||
| 7 | + | hexcheck: | |||
| 8 | + | type: module | |||
| 9 | + | description: Checks hexagonal architecture boundaries. | |||
| 10 | + | settings: | |||
| 11 | + | config: .hexcheck.yaml | |||
M go.mod +1 -0
| @@ -3,6 +3,7 @@ module github.com/bnema/hexcheck | |||
| 3 | 3 | go 1.25.0 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | + | github.com/golangci/plugin-module-register v0.1.2 // indirect | |
| 6 | 7 | golang.org/x/mod v0.36.0 // indirect | |
| 7 | 8 | golang.org/x/sync v0.20.0 // indirect | |
| 8 | 9 | golang.org/x/tools v0.45.0 // indirect | |
| @@ -3,6 +3,7 @@ module github.com/bnema/hexcheck | |||||
| 3 | go 1.25.0 | 3 | go 1.25.0 | ||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | + | github.com/golangci/plugin-module-register v0.1.2 // indirect | |||
| 6 | golang.org/x/mod v0.36.0 // indirect | 7 | golang.org/x/mod v0.36.0 // indirect | ||
| 7 | golang.org/x/sync v0.20.0 // indirect | 8 | golang.org/x/sync v0.20.0 // indirect | ||
| 8 | golang.org/x/tools v0.45.0 // indirect | 9 | golang.org/x/tools v0.45.0 // indirect | ||
M go.sum +2 -0 generated file: collapsed
A golangci/plugin.go +69 -0
| @@ -0,0 +1,69 @@ | |||
| 1 | + | package golangci | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/hexcheck/analyzer" | |
| 7 | + | "github.com/golangci/plugin-module-register/register" | |
| 8 | + | "golang.org/x/tools/go/analysis" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func init() { | |
| 12 | + | register.Plugin(analyzer.Name, New) | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func New(settings any) (register.LinterPlugin, error) { | |
| 16 | + | cfg, err := parseSettings(settings) | |
| 17 | + | if err != nil { | |
| 18 | + | return nil, err | |
| 19 | + | } | |
| 20 | + | return plugin{configPath: cfg.ConfigPath, root: cfg.Root, modulePath: cfg.ModulePath}, nil | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | type plugin struct { | |
| 24 | + | configPath string | |
| 25 | + | root string | |
| 26 | + | modulePath string | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (p plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { | |
| 30 | + | return []*analysis.Analyzer{analyzer.New(analyzer.Options{ | |
| 31 | + | ConfigPath: p.configPath, | |
| 32 | + | Root: p.root, | |
| 33 | + | ModulePath: p.modulePath, | |
| 34 | + | })}, nil | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func (p plugin) GetLoadMode() string { | |
| 38 | + | return register.LoadModeTypesInfo | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | type settingsConfig struct { | |
| 42 | + | ConfigPath string | |
| 43 | + | Root string | |
| 44 | + | ModulePath string | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func parseSettings(settings any) (settingsConfig, error) { | |
| 48 | + | if settings == nil { | |
| 49 | + | return settingsConfig{}, nil | |
| 50 | + | } | |
| 51 | + | m, ok := settings.(map[string]any) | |
| 52 | + | if !ok { | |
| 53 | + | return settingsConfig{}, fmt.Errorf("hexcheck settings must be a map, got %T", settings) | |
| 54 | + | } | |
| 55 | + | return settingsConfig{ | |
| 56 | + | ConfigPath: stringSetting(m, "config"), | |
| 57 | + | Root: stringSetting(m, "root"), | |
| 58 | + | ModulePath: stringSetting(m, "module"), | |
| 59 | + | }, nil | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func stringSetting(settings map[string]any, key string) string { | |
| 63 | + | value, ok := settings[key] | |
| 64 | + | if !ok || value == nil { | |
| 65 | + | return "" | |
| 66 | + | } | |
| 67 | + | s, _ := value.(string) | |
| 68 | + | return s | |
| 69 | + | } | |
| @@ -0,0 +1,69 @@ | |||||
| 1 | + | package golangci | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/hexcheck/analyzer" | |||
| 7 | + | "github.com/golangci/plugin-module-register/register" | |||
| 8 | + | "golang.org/x/tools/go/analysis" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func init() { | |||
| 12 | + | register.Plugin(analyzer.Name, New) | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func New(settings any) (register.LinterPlugin, error) { | |||
| 16 | + | cfg, err := parseSettings(settings) | |||
| 17 | + | if err != nil { | |||
| 18 | + | return nil, err | |||
| 19 | + | } | |||
| 20 | + | return plugin{configPath: cfg.ConfigPath, root: cfg.Root, modulePath: cfg.ModulePath}, nil | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | type plugin struct { | |||
| 24 | + | configPath string | |||
| 25 | + | root string | |||
| 26 | + | modulePath string | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (p plugin) BuildAnalyzers() ([]*analysis.Analyzer, error) { | |||
| 30 | + | return []*analysis.Analyzer{analyzer.New(analyzer.Options{ | |||
| 31 | + | ConfigPath: p.configPath, | |||
| 32 | + | Root: p.root, | |||
| 33 | + | ModulePath: p.modulePath, | |||
| 34 | + | })}, nil | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | func (p plugin) GetLoadMode() string { | |||
| 38 | + | return register.LoadModeTypesInfo | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | type settingsConfig struct { | |||
| 42 | + | ConfigPath string | |||
| 43 | + | Root string | |||
| 44 | + | ModulePath string | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func parseSettings(settings any) (settingsConfig, error) { | |||
| 48 | + | if settings == nil { | |||
| 49 | + | return settingsConfig{}, nil | |||
| 50 | + | } | |||
| 51 | + | m, ok := settings.(map[string]any) | |||
| 52 | + | if !ok { | |||
| 53 | + | return settingsConfig{}, fmt.Errorf("hexcheck settings must be a map, got %T", settings) | |||
| 54 | + | } | |||
| 55 | + | return settingsConfig{ | |||
| 56 | + | ConfigPath: stringSetting(m, "config"), | |||
| 57 | + | Root: stringSetting(m, "root"), | |||
| 58 | + | ModulePath: stringSetting(m, "module"), | |||
| 59 | + | }, nil | |||
| 60 | + | } | |||
| 61 | + | ||||
| 62 | + | func stringSetting(settings map[string]any, key string) string { | |||
| 63 | + | value, ok := settings[key] | |||
| 64 | + | if !ok || value == nil { | |||
| 65 | + | return "" | |||
| 66 | + | } | |||
| 67 | + | s, _ := value.(string) | |||
| 68 | + | return s | |||
| 69 | + | } | |||
A golangci/plugin_test.go +53 -0
| @@ -0,0 +1,53 @@ | |||
| 1 | + | package golangci | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestNew(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | settings any | |
| 9 | + | wantErr bool | |
| 10 | + | wantAnalyzer string | |
| 11 | + | }{ | |
| 12 | + | { | |
| 13 | + | name: "nil settings", | |
| 14 | + | settings: nil, | |
| 15 | + | wantAnalyzer: "hexcheck", | |
| 16 | + | }, | |
| 17 | + | { | |
| 18 | + | name: "config settings", | |
| 19 | + | settings: map[string]any{"config": ".hexcheck.yaml", "module": "example.com/project"}, | |
| 20 | + | wantAnalyzer: "hexcheck", | |
| 21 | + | }, | |
| 22 | + | { | |
| 23 | + | name: "invalid settings type", | |
| 24 | + | settings: "bad", | |
| 25 | + | wantErr: true, | |
| 26 | + | }, | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | for _, tt := range tests { | |
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |
| 31 | + | p, err := New(tt.settings) | |
| 32 | + | if tt.wantErr { | |
| 33 | + | if err == nil { | |
| 34 | + | t.Fatal("expected error") | |
| 35 | + | } | |
| 36 | + | return | |
| 37 | + | } | |
| 38 | + | if err != nil { | |
| 39 | + | t.Fatal(err) | |
| 40 | + | } | |
| 41 | + | analyzers, err := p.BuildAnalyzers() | |
| 42 | + | if err != nil { | |
| 43 | + | t.Fatal(err) | |
| 44 | + | } | |
| 45 | + | if len(analyzers) != 1 || analyzers[0].Name != tt.wantAnalyzer { | |
| 46 | + | t.Fatalf("unexpected analyzers: %#v", analyzers) | |
| 47 | + | } | |
| 48 | + | if p.GetLoadMode() == "" { | |
| 49 | + | t.Fatal("load mode is empty") | |
| 50 | + | } | |
| 51 | + | }) | |
| 52 | + | } | |
| 53 | + | } | |
| @@ -0,0 +1,53 @@ | |||||
| 1 | + | package golangci | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestNew(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | settings any | |||
| 9 | + | wantErr bool | |||
| 10 | + | wantAnalyzer string | |||
| 11 | + | }{ | |||
| 12 | + | { | |||
| 13 | + | name: "nil settings", | |||
| 14 | + | settings: nil, | |||
| 15 | + | wantAnalyzer: "hexcheck", | |||
| 16 | + | }, | |||
| 17 | + | { | |||
| 18 | + | name: "config settings", | |||
| 19 | + | settings: map[string]any{"config": ".hexcheck.yaml", "module": "example.com/project"}, | |||
| 20 | + | wantAnalyzer: "hexcheck", | |||
| 21 | + | }, | |||
| 22 | + | { | |||
| 23 | + | name: "invalid settings type", | |||
| 24 | + | settings: "bad", | |||
| 25 | + | wantErr: true, | |||
| 26 | + | }, | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | for _, tt := range tests { | |||
| 30 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 31 | + | p, err := New(tt.settings) | |||
| 32 | + | if tt.wantErr { | |||
| 33 | + | if err == nil { | |||
| 34 | + | t.Fatal("expected error") | |||
| 35 | + | } | |||
| 36 | + | return | |||
| 37 | + | } | |||
| 38 | + | if err != nil { | |||
| 39 | + | t.Fatal(err) | |||
| 40 | + | } | |||
| 41 | + | analyzers, err := p.BuildAnalyzers() | |||
| 42 | + | if err != nil { | |||
| 43 | + | t.Fatal(err) | |||
| 44 | + | } | |||
| 45 | + | if len(analyzers) != 1 || analyzers[0].Name != tt.wantAnalyzer { | |||
| 46 | + | t.Fatalf("unexpected analyzers: %#v", analyzers) | |||
| 47 | + | } | |||
| 48 | + | if p.GetLoadMode() == "" { | |||
| 49 | + | t.Fatal("load mode is empty") | |||
| 50 | + | } | |||
| 51 | + | }) | |||
| 52 | + | } | |||
| 53 | + | } | |||