detached f23cdad

feat(golangci): add module plugin integration

commit
f23cdad5a73aeec150fb1fc289c44520d1d8646d
author
brice · · 2026-05-30 05:01 UTC
parent
0796ef4
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
12 files · +323 -56
M README.md +10 -4
@@ -59,10 +59,16 @@ hexcheck --config .hexcheck.yaml ./...
5959
6060 ## golangci-lint module plugin
6161
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:
6369
6470 ```yaml
65-# .custom-gcl.yml
71+# examples/custom-gcl.yml
6672 version: v2.12.2
6773 name: hex-golangci-lint
6874 destination: ./bin
@@ -72,10 +78,10 @@ plugins:
7278 version: v0.1.0
7379 ```
7480
75-Enable it:
81+Enable it in a project:
7682
7783 ```yaml
78-# .golangci.yml
84+# examples/golangci.yml
7985 version: "2"
8086 linters:
8187 enable:
@@ -59,10 +59,16 @@ hexcheck --config .hexcheck.yaml ./...
59 59
60 ## golangci-lint module plugin60 ## 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 ```yaml70 ```yaml
65-# .custom-gcl.yml71+# examples/custom-gcl.yml
66 version: v2.12.272 version: v2.12.2
67 name: hex-golangci-lint73 name: hex-golangci-lint
68 destination: ./bin74 destination: ./bin
@@ -72,10 +78,10 @@ plugins:
72 version: v0.1.078 version: v0.1.0
73 ```79 ```
74 80
75-Enable it:81+Enable it in a project:
76 82
77 ```yaml83 ```yaml
78-# .golangci.yml84+# 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 (
77 "golang.org/x/tools/go/analysis/analysistest"
88 )
99
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+ }
1830
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+ }
2436 }
2537
2638 func testConfig() *config.Config {
@@ -37,6 +49,9 @@ func testConfig() *config.Config {
3749 ExternalTypes: config.ExternalTypes{
3850 FrameworkPackages: []string{"example.com/framework"},
3951 },
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+ },
4055 }
4156 if err := cfg.Validate(); err != nil {
4257 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
22
33 import (
44 "go/ast"
5+ "go/types"
56 "strconv"
67 "strings"
78
@@ -22,6 +23,11 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match
2223 }
2324 }
2425
26+ portInterfaces := r.importedPortInterfaces(file)
27+ if len(portInterfaces) == 0 {
28+ return
29+ }
30+
2531 ast.Inspect(file, func(n ast.Node) bool {
2632 ts, ok := n.(*ast.TypeSpec)
2733 if !ok {
@@ -29,9 +35,48 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match
2935 }
3036
3137 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+ }
3450 }
3551 return true
3652 })
3753 }
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 true51 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 (
77 )
88
99 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
1319 components:
1420 core:
1521 paths: [internal/domain/**]
@@ -23,25 +29,56 @@ allow:
2329 - rule: no-local-fakes-for-ports
2430 path: internal/foo_test.go
2531 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+ },
4259 }
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+ })
4582 }
4683 }
4784
@@ -53,22 +90,20 @@ func TestComponentForPathPrecedence(t *testing.T) {
5390 }}
5491 cfg.applyDefaults()
5592
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},
59100 }
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+ })
73108 }
74109 }
@@ -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: 112+ 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-ports29 - rule: no-local-fakes-for-ports
24 path: internal/foo_test.go30 path: internal/foo_test.go
25 reason: stateful fake31 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
33 go 1.25.0
44
55 require (
6+ github.com/golangci/plugin-module-register v0.1.2 // indirect
67 golang.org/x/mod v0.36.0 // indirect
78 golang.org/x/sync v0.20.0 // indirect
89 golang.org/x/tools v0.45.0 // indirect
@@ -3,6 +3,7 @@ module github.com/bnema/hexcheck
3 go 1.25.03 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 // indirect7 golang.org/x/mod v0.36.0 // indirect
7 golang.org/x/sync v0.20.0 // indirect8 golang.org/x/sync v0.20.0 // indirect
8 golang.org/x/tools v0.45.0 // indirect9 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+}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close