detached 3e9690d

feat(analyzer): enforce boundary rules

commit
3e9690d1753c43233070e732d5e33d0c0f932ef9
author
brice · · 2026-05-30 04:57 UTC
parent
996ed79
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
19 files · +771 -6
M Makefile +7 -3
@@ -1,4 +1,4 @@
1-.PHONY: test lint tidy check smoke-dumber
1+.PHONY: test lint tidy check smoke-local
22
33 test:
44 go test ./...
@@ -11,5 +11,9 @@ tidy:
1111
1212 check: tidy test
1313
14-smoke-dumber:
15- HEXCHECK_DUMBER_PATH=/home/brice/dev/projects/dumber go test ./test/smoke
14+smoke-local:
15+ @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \
16+ echo "HEXCHECK_SMOKE_REPO must point to a local Go repository for smoke testing"; \
17+ exit 1; \
18+ fi
19+ go test ./test/smoke
@@ -1,4 +1,4 @@
1-.PHONY: test lint tidy check smoke-dumber1+.PHONY: test lint tidy check smoke-local
2 2
3 test:3 test:
4 go test ./...4 go test ./...
@@ -11,5 +11,9 @@ tidy:
11 11
12 check: tidy test12 check: tidy test
13 13
14-smoke-dumber:14+smoke-local:
15- HEXCHECK_DUMBER_PATH=/home/brice/dev/projects/dumber go test ./test/smoke15+ @if [ -z "$(HEXCHECK_SMOKE_REPO)" ]; then \
16+ echo "HEXCHECK_SMOKE_REPO must point to a local Go repository for smoke testing"; \
17+ exit 1; \
18+ fi
19+ go test ./test/smoke
M README.md +2 -2
@@ -10,7 +10,7 @@ It is built on `golang.org/x/tools/go/analysis`, runs as a standalone CLI, and c
1010 - catch deterministic boundary violations in CI
1111 - warn about suspicious business logic in adapters and entrypoints
1212 - warn when tests bypass generated interface mocks with local fakes or real adapters
13-- smoke test the analyzer against real Go repositories such as Dumber
13+- smoke test the analyzer against real Go repositories through opt-in local paths
1414
1515 ## Quick example
1616
@@ -94,5 +94,5 @@ linters:
9494 ```bash
9595 make test
9696 make check
97-HEXCHECK_DUMBER_PATH=/home/brice/dev/projects/dumber make smoke-dumber
97+HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local
9898 ```
@@ -10,7 +10,7 @@ It is built on `golang.org/x/tools/go/analysis`, runs as a standalone CLI, and c
10 - catch deterministic boundary violations in CI10 - catch deterministic boundary violations in CI
11 - warn about suspicious business logic in adapters and entrypoints11 - warn about suspicious business logic in adapters and entrypoints
12 - warn when tests bypass generated interface mocks with local fakes or real adapters12 - warn when tests bypass generated interface mocks with local fakes or real adapters
13-- smoke test the analyzer against real Go repositories such as Dumber13+- smoke test the analyzer against real Go repositories through opt-in local paths
14 14
15 ## Quick example15 ## Quick example
16 16
@@ -94,5 +94,5 @@ linters:
94 ```bash94 ```bash
95 make test95 make test
96 make check96 make check
97-HEXCHECK_DUMBER_PATH=/home/brice/dev/projects/dumber make smoke-dumber97+HEXCHECK_SMOKE_REPO=/path/to/local/go/repo make smoke-local
98 ```98 ```
A analyzer/analyzer.go +193 -0
@@ -0,0 +1,193 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "go/types"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/hexcheck/config"
11+ "golang.org/x/tools/go/analysis"
12+)
13+
14+const Name = "hexcheck"
15+
16+type Options struct {
17+ Config *config.Config
18+ ModulePath string
19+}
20+
21+func New(opts Options) *analysis.Analyzer {
22+ cfg := opts.Config
23+ if cfg == nil {
24+ cfg = config.Default()
25+ }
26+ return &analysis.Analyzer{
27+ Name: Name,
28+ Doc: "checks hexagonal architecture boundaries",
29+ Run: func(pass *analysis.Pass) (any, error) {
30+ r := runner{pass: pass, cfg: cfg, modulePath: opts.ModulePath}
31+ r.run()
32+ return nil, nil
33+ },
34+ }
35+}
36+
37+type runner struct {
38+ pass *analysis.Pass
39+ cfg *config.Config
40+ modulePath string
41+}
42+
43+func (r runner) run() {
44+ pkgRel := r.relImportPath(r.pass.Pkg.Path())
45+ current, ok := r.cfg.ComponentForPath(pkgRel)
46+ if !ok || current.Role == config.RoleIgnore {
47+ return
48+ }
49+ for _, file := range r.pass.Files {
50+ filePath := r.filePath(file)
51+ r.checkImports(file, filePath, current)
52+ r.checkTypeLeaks(file, filePath, current)
53+ r.checkBusinessLogic(file, filePath, current)
54+ r.checkMocks(file, filePath, current)
55+ }
56+}
57+
58+func (r runner) checkImports(file *ast.File, filePath string, current config.Match) {
59+ for _, imp := range file.Imports {
60+ importPath, _ := strconv.Unquote(imp.Path.Value)
61+ importRel := r.relImportPath(importPath)
62+ imported, ok := r.cfg.ComponentForPath(importRel)
63+ if !ok || imported.Role == config.RoleIgnore {
64+ continue
65+ }
66+ switch {
67+ case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
68+ r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
69+ case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:
70+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
71+ case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
72+ r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
73+ }
74+ }
75+}
76+
77+func (r runner) checkTypeLeaks(file *ast.File, filePath string, current config.Match) {
78+ if current.Role != config.RolePorts && current.Role != config.RoleCore {
79+ return
80+ }
81+ ast.Inspect(file, func(n ast.Node) bool {
82+ switch node := n.(type) {
83+ case *ast.FuncDecl:
84+ if current.Role == config.RoleCore && node.Name.IsExported() {
85+ r.checkFieldList(node.Type.Params, filePath, "no-framework-types-in-core")
86+ r.checkFieldList(node.Type.Results, filePath, "no-framework-types-in-core")
87+ }
88+ case *ast.TypeSpec:
89+ if current.Role == config.RolePorts {
90+ if iface, ok := node.Type.(*ast.InterfaceType); ok {
91+ for _, method := range iface.Methods.List {
92+ if fn, ok := method.Type.(*ast.FuncType); ok {
93+ r.checkFieldList(fn.Params, filePath, "no-infra-types-in-ports")
94+ r.checkFieldList(fn.Results, filePath, "no-infra-types-in-ports")
95+ }
96+ }
97+ }
98+ }
99+ if current.Role == config.RoleCore && node.Name.IsExported() {
100+ if st, ok := node.Type.(*ast.StructType); ok {
101+ r.checkFieldList(st.Fields, filePath, "no-framework-types-in-core")
102+ }
103+ }
104+ }
105+ return true
106+ })
107+}
108+
109+func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
110+ if fields == nil {
111+ return
112+ }
113+ for _, field := range fields.List {
114+ if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {
115+ r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))
116+ }
117+ }
118+}
119+
120+func (r runner) isInfraType(t types.Type) bool {
121+ if t == nil {
122+ return false
123+ }
124+ switch tt := t.(type) {
125+ case *types.Pointer:
126+ return r.isInfraType(tt.Elem())
127+ case *types.Slice:
128+ return r.isInfraType(tt.Elem())
129+ case *types.Array:
130+ return r.isInfraType(tt.Elem())
131+ case *types.Map:
132+ return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())
133+ case *types.Named:
134+ obj := tt.Obj()
135+ if obj == nil || obj.Pkg() == nil {
136+ return false
137+ }
138+ pkgPath := obj.Pkg().Path()
139+ rel := r.relImportPath(pkgPath)
140+ if match, ok := r.cfg.ComponentForPath(rel); ok && (match.Role == config.RoleAdapter || match.Role == config.RoleEntrypoint) {
141+ return true
142+ }
143+ return r.matchesExternal(pkgPath) || r.matchesExternal(rel)
144+ default:
145+ return false
146+ }
147+}
148+
149+func (r runner) matchesExternal(pkg string) bool {
150+ for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) {
151+ if simpleMatch(pattern, pkg) {
152+ return true
153+ }
154+ }
155+ return false
156+}
157+
158+func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) {
159+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) {
160+ return
161+ }
162+ r.pass.Reportf(pos, rule+": "+format, args...)
163+}
164+
165+func (r runner) relImportPath(importPath string) string {
166+ if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {
167+ return strings.TrimPrefix(importPath, r.modulePath+"/")
168+ }
169+ if r.modulePath != "" && importPath == r.modulePath {
170+ return ""
171+ }
172+ return importPath
173+}
174+
175+func (r runner) filePath(file *ast.File) string {
176+ pos := r.pass.Fset.Position(file.Pos()).Filename
177+ pos = strings.ReplaceAll(pos, "\\", "/")
178+ if idx := strings.LastIndex(pos, "/src/"+r.modulePath+"/"); idx >= 0 && r.modulePath != "" {
179+ return pos[idx+len("/src/")+len(r.modulePath)+1:]
180+ }
181+ if r.cfg.Root != "" {
182+ root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
183+ if strings.HasPrefix(pos, root+"/") {
184+ return strings.TrimPrefix(pos, root+"/")
185+ }
186+ }
187+ return pos
188+}
189+
190+func simpleMatch(pattern, name string) bool {
191+ pattern = strings.TrimSuffix(pattern, "/**")
192+ return name == pattern || strings.HasPrefix(name, pattern+"/")
193+}
@@ -0,0 +1,193 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "go/types"
7+ "strconv"
8+ "strings"
9+
10+ "github.com/bnema/hexcheck/config"
11+ "golang.org/x/tools/go/analysis"
12+)
13+
14+const Name = "hexcheck"
15+
16+type Options struct {
17+ Config *config.Config
18+ ModulePath string
19+}
20+
21+func New(opts Options) *analysis.Analyzer {
22+ cfg := opts.Config
23+ if cfg == nil {
24+ cfg = config.Default()
25+ }
26+ return &analysis.Analyzer{
27+ Name: Name,
28+ Doc: "checks hexagonal architecture boundaries",
29+ Run: func(pass *analysis.Pass) (any, error) {
30+ r := runner{pass: pass, cfg: cfg, modulePath: opts.ModulePath}
31+ r.run()
32+ return nil, nil
33+ },
34+ }
35+}
36+
37+type runner struct {
38+ pass *analysis.Pass
39+ cfg *config.Config
40+ modulePath string
41+}
42+
43+func (r runner) run() {
44+ pkgRel := r.relImportPath(r.pass.Pkg.Path())
45+ current, ok := r.cfg.ComponentForPath(pkgRel)
46+ if !ok || current.Role == config.RoleIgnore {
47+ return
48+ }
49+ for _, file := range r.pass.Files {
50+ filePath := r.filePath(file)
51+ r.checkImports(file, filePath, current)
52+ r.checkTypeLeaks(file, filePath, current)
53+ r.checkBusinessLogic(file, filePath, current)
54+ r.checkMocks(file, filePath, current)
55+ }
56+}
57+
58+func (r runner) checkImports(file *ast.File, filePath string, current config.Match) {
59+ for _, imp := range file.Imports {
60+ importPath, _ := strconv.Unquote(imp.Path.Value)
61+ importRel := r.relImportPath(importPath)
62+ imported, ok := r.cfg.ComponentForPath(importRel)
63+ if !ok || imported.Role == config.RoleIgnore {
64+ continue
65+ }
66+ switch {
67+ case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
68+ r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
69+ case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:
70+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
71+ case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
72+ r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
73+ }
74+ }
75+}
76+
77+func (r runner) checkTypeLeaks(file *ast.File, filePath string, current config.Match) {
78+ if current.Role != config.RolePorts && current.Role != config.RoleCore {
79+ return
80+ }
81+ ast.Inspect(file, func(n ast.Node) bool {
82+ switch node := n.(type) {
83+ case *ast.FuncDecl:
84+ if current.Role == config.RoleCore && node.Name.IsExported() {
85+ r.checkFieldList(node.Type.Params, filePath, "no-framework-types-in-core")
86+ r.checkFieldList(node.Type.Results, filePath, "no-framework-types-in-core")
87+ }
88+ case *ast.TypeSpec:
89+ if current.Role == config.RolePorts {
90+ if iface, ok := node.Type.(*ast.InterfaceType); ok {
91+ for _, method := range iface.Methods.List {
92+ if fn, ok := method.Type.(*ast.FuncType); ok {
93+ r.checkFieldList(fn.Params, filePath, "no-infra-types-in-ports")
94+ r.checkFieldList(fn.Results, filePath, "no-infra-types-in-ports")
95+ }
96+ }
97+ }
98+ }
99+ if current.Role == config.RoleCore && node.Name.IsExported() {
100+ if st, ok := node.Type.(*ast.StructType); ok {
101+ r.checkFieldList(st.Fields, filePath, "no-framework-types-in-core")
102+ }
103+ }
104+ }
105+ return true
106+ })
107+}
108+
109+func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
110+ if fields == nil {
111+ return
112+ }
113+ for _, field := range fields.List {
114+ if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {
115+ r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))
116+ }
117+ }
118+}
119+
120+func (r runner) isInfraType(t types.Type) bool {
121+ if t == nil {
122+ return false
123+ }
124+ switch tt := t.(type) {
125+ case *types.Pointer:
126+ return r.isInfraType(tt.Elem())
127+ case *types.Slice:
128+ return r.isInfraType(tt.Elem())
129+ case *types.Array:
130+ return r.isInfraType(tt.Elem())
131+ case *types.Map:
132+ return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())
133+ case *types.Named:
134+ obj := tt.Obj()
135+ if obj == nil || obj.Pkg() == nil {
136+ return false
137+ }
138+ pkgPath := obj.Pkg().Path()
139+ rel := r.relImportPath(pkgPath)
140+ if match, ok := r.cfg.ComponentForPath(rel); ok && (match.Role == config.RoleAdapter || match.Role == config.RoleEntrypoint) {
141+ return true
142+ }
143+ return r.matchesExternal(pkgPath) || r.matchesExternal(rel)
144+ default:
145+ return false
146+ }
147+}
148+
149+func (r runner) matchesExternal(pkg string) bool {
150+ for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) {
151+ if simpleMatch(pattern, pkg) {
152+ return true
153+ }
154+ }
155+ return false
156+}
157+
158+func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) {
159+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) {
160+ return
161+ }
162+ r.pass.Reportf(pos, rule+": "+format, args...)
163+}
164+
165+func (r runner) relImportPath(importPath string) string {
166+ if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {
167+ return strings.TrimPrefix(importPath, r.modulePath+"/")
168+ }
169+ if r.modulePath != "" && importPath == r.modulePath {
170+ return ""
171+ }
172+ return importPath
173+}
174+
175+func (r runner) filePath(file *ast.File) string {
176+ pos := r.pass.Fset.Position(file.Pos()).Filename
177+ pos = strings.ReplaceAll(pos, "\\", "/")
178+ if idx := strings.LastIndex(pos, "/src/"+r.modulePath+"/"); idx >= 0 && r.modulePath != "" {
179+ return pos[idx+len("/src/")+len(r.modulePath)+1:]
180+ }
181+ if r.cfg.Root != "" {
182+ root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
183+ if strings.HasPrefix(pos, root+"/") {
184+ return strings.TrimPrefix(pos, root+"/")
185+ }
186+ }
187+ return pos
188+}
189+
190+func simpleMatch(pattern, name string) bool {
191+ pattern = strings.TrimSuffix(pattern, "/**")
192+ return name == pattern || strings.HasPrefix(name, pattern+"/")
193+}
A analyzer/analyzer_test.go +45 -0
@@ -0,0 +1,45 @@
1+package analyzer
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/hexcheck/config"
7+ "golang.org/x/tools/go/analysis/analysistest"
8+)
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+}
18+
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+ )
24+}
25+
26+func testConfig() *config.Config {
27+ cfg := &config.Config{
28+ Version: 1,
29+ Components: map[string]config.Component{
30+ "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}},
31+ "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}},
32+ "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
33+ "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
34+ "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**"}},
35+ },
36+ Rules: config.DefaultRuleSeverities(),
37+ ExternalTypes: config.ExternalTypes{
38+ FrameworkPackages: []string{"example.com/framework"},
39+ },
40+ }
41+ if err := cfg.Validate(); err != nil {
42+ panic(err)
43+ }
44+ return cfg
45+}
@@ -0,0 +1,45 @@
1+package analyzer
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/hexcheck/config"
7+ "golang.org/x/tools/go/analysis/analysistest"
8+)
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+}
18+
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+ )
24+}
25+
26+func testConfig() *config.Config {
27+ cfg := &config.Config{
28+ Version: 1,
29+ Components: map[string]config.Component{
30+ "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}},
31+ "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}},
32+ "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
33+ "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
34+ "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**"}},
35+ },
36+ Rules: config.DefaultRuleSeverities(),
37+ ExternalTypes: config.ExternalTypes{
38+ FrameworkPackages: []string{"example.com/framework"},
39+ },
40+ }
41+ if err := cfg.Validate(); err != nil {
42+ panic(err)
43+ }
44+ return cfg
45+}
A analyzer/business_logic.go +41 -0
@@ -0,0 +1,41 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "strings"
6+
7+ "github.com/bnema/hexcheck/config"
8+)
9+
10+func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) {
11+ if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
12+ return
13+ }
14+
15+ threshold := r.cfg.Heuristics.BusinessLogicThreshold
16+ for _, decl := range file.Decls {
17+ fn, ok := decl.(*ast.FuncDecl)
18+ if !ok || fn.Body == nil {
19+ continue
20+ }
21+
22+ score := 0
23+ for _, keyword := range r.cfg.Heuristics.BusinessKeywords {
24+ if strings.Contains(fn.Name.Name, keyword) {
25+ score += 2
26+ }
27+ }
28+
29+ ast.Inspect(fn.Body, func(n ast.Node) bool {
30+ switch n.(type) {
31+ case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
32+ score++
33+ }
34+ return true
35+ })
36+
37+ if score >= threshold {
38+ r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has suspicious business-logic score %d", fn.Name.Name, score)
39+ }
40+ }
41+}
@@ -0,0 +1,41 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "strings"
6+
7+ "github.com/bnema/hexcheck/config"
8+)
9+
10+func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) {
11+ if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
12+ return
13+ }
14+
15+ threshold := r.cfg.Heuristics.BusinessLogicThreshold
16+ for _, decl := range file.Decls {
17+ fn, ok := decl.(*ast.FuncDecl)
18+ if !ok || fn.Body == nil {
19+ continue
20+ }
21+
22+ score := 0
23+ for _, keyword := range r.cfg.Heuristics.BusinessKeywords {
24+ if strings.Contains(fn.Name.Name, keyword) {
25+ score += 2
26+ }
27+ }
28+
29+ ast.Inspect(fn.Body, func(n ast.Node) bool {
30+ switch n.(type) {
31+ case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
32+ score++
33+ }
34+ return true
35+ })
36+
37+ if score >= threshold {
38+ r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has suspicious business-logic score %d", fn.Name.Name, score)
39+ }
40+ }
41+}
A analyzer/mocks.go +37 -0
@@ -0,0 +1,37 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+)
10+
11+func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
12+ if !strings.HasSuffix(filePath, "_test.go") {
13+ return
14+ }
15+
16+ if current.Role == config.RoleUsecase || current.Role == config.RoleCore {
17+ for _, imp := range file.Imports {
18+ importPath, _ := strconv.Unquote(imp.Path.Value)
19+ if match, ok := r.cfg.ComponentForPath(r.relImportPath(importPath)); ok && match.Role == config.RoleAdapter {
20+ r.report(imp.Pos(), "prefer-generated-mocks", filePath, "test imports concrete adapter %s; prefer generated mocks for port dependencies", importPath)
21+ }
22+ }
23+ }
24+
25+ ast.Inspect(file, func(n ast.Node) bool {
26+ ts, ok := n.(*ast.TypeSpec)
27+ if !ok {
28+ return true
29+ }
30+
31+ 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)
34+ }
35+ return true
36+ })
37+}
@@ -0,0 +1,37 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "strconv"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+)
10+
11+func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
12+ if !strings.HasSuffix(filePath, "_test.go") {
13+ return
14+ }
15+
16+ if current.Role == config.RoleUsecase || current.Role == config.RoleCore {
17+ for _, imp := range file.Imports {
18+ importPath, _ := strconv.Unquote(imp.Path.Value)
19+ if match, ok := r.cfg.ComponentForPath(r.relImportPath(importPath)); ok && match.Role == config.RoleAdapter {
20+ r.report(imp.Pos(), "prefer-generated-mocks", filePath, "test imports concrete adapter %s; prefer generated mocks for port dependencies", importPath)
21+ }
22+ }
23+ }
24+
25+ ast.Inspect(file, func(n ast.Node) bool {
26+ ts, ok := n.(*ast.TypeSpec)
27+ if !ok {
28+ return true
29+ }
30+
31+ 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)
34+ }
35+ return true
36+ })
37+}
A analyzer/testdata/src/example.com/framework/framework.go +3 -0
@@ -0,0 +1,3 @@
1+package framework
2+
3+type Context struct{}
@@ -0,0 +1,3 @@
1+package framework
2+
3+type Context struct{}
A analyzer/testdata/src/example.com/project/internal/application/port/port.go +7 -0
@@ -0,0 +1,7 @@
1+package port
2+
3+import "example.com/project/internal/infrastructure/sql"
4+
5+type UserRepository interface {
6+ Find() (*sql.Row, error) // want "no-infra-types-in-ports"
7+}
@@ -0,0 +1,7 @@
1+package port
2+
3+import "example.com/project/internal/infrastructure/sql"
4+
5+type UserRepository interface {
6+ Find() (*sql.Row, error) // want "no-infra-types-in-ports"
7+}
A analyzer/testdata/src/example.com/project/internal/application/usecase/usecase.go +3 -0
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-usecase"
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-usecase"
A analyzer/testdata/src/example.com/project/internal/domain/domain.go +5 -0
@@ -0,0 +1,5 @@
1+package domain
2+
3+import _ "example.com/project/internal/infrastructure/http" // want "no-adapter-imports-in-core"
4+
5+type User struct{ Name string }
@@ -0,0 +1,5 @@
1+package domain
2+
3+import _ "example.com/project/internal/infrastructure/http" // want "no-adapter-imports-in-core"
4+
5+type User struct{ Name string }
A analyzer/testdata/src/example.com/project/internal/domain/framework.go +7 -0
@@ -0,0 +1,7 @@
1+package domain
2+
3+import "example.com/framework"
4+
5+type Service struct {
6+ Context *framework.Context // want "no-framework-types-in-core"
7+}
@@ -0,0 +1,7 @@
1+package domain
2+
3+import "example.com/framework"
4+
5+type Service struct {
6+ Context *framework.Context // want "no-framework-types-in-core"
7+}
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/http.go +3 -0
@@ -0,0 +1,3 @@
1+package http
2+
3+import _ "example.com/project/internal/infrastructure/sql" // want "no-adapter-to-adapter-imports"
@@ -0,0 +1,3 @@
1+package http
2+
3+import _ "example.com/project/internal/infrastructure/sql" // want "no-adapter-to-adapter-imports"
A analyzer/testdata/src/example.com/project/internal/infrastructure/sql/sql.go +3 -0
@@ -0,0 +1,3 @@
1+package sql
2+
3+type Row struct{ ID string }
@@ -0,0 +1,3 @@
1+package sql
2+
3+type Row struct{ ID string }
A config/config.go +245 -0
@@ -0,0 +1,245 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+
10+ "github.com/bnema/hexcheck/internal/glob"
11+ "gopkg.in/yaml.v3"
12+)
13+
14+type Role string
15+
16+const (
17+ RoleCore Role = "core"
18+ RoleUsecase Role = "usecase"
19+ RolePorts Role = "ports"
20+ RoleAdapter Role = "adapter"
21+ RoleEntrypoint Role = "entrypoint"
22+ RoleIgnore Role = "ignore"
23+)
24+
25+type Severity string
26+
27+const (
28+ SeverityOff Severity = "off"
29+ SeverityWarn Severity = "warn"
30+ SeverityError Severity = "error"
31+)
32+
33+type Config struct {
34+ Version int `yaml:"version"`
35+ Components map[string]Component `yaml:"components"`
36+ Rules map[string]Severity `yaml:"rules"`
37+ ExternalTypes ExternalTypes `yaml:"externalTypes"`
38+ Heuristics Heuristics `yaml:"heuristics"`
39+ Mocking Mocking `yaml:"mocking"`
40+ Allow []Allow `yaml:"allow"`
41+ Root string `yaml:"-"`
42+}
43+
44+type Component struct {
45+ Role Role `yaml:"role"`
46+ Paths []string `yaml:"paths"`
47+}
48+
49+type ExternalTypes struct {
50+ FrameworkPackages []string `yaml:"frameworkPackages"`
51+ AdapterTypePackages []string `yaml:"adapterTypePackages"`
52+}
53+
54+type Heuristics struct {
55+ BusinessLogicThreshold int `yaml:"businessLogicThreshold"`
56+ BusinessKeywords []string `yaml:"businessKeywords"`
57+}
58+
59+type Mocking struct {
60+ GeneratedMockPaths []string `yaml:"generatedMockPaths"`
61+ GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"`
62+}
63+
64+type Allow struct {
65+ Rule string `yaml:"rule"`
66+ Path string `yaml:"path"`
67+ Reason string `yaml:"reason"`
68+}
69+
70+type Match struct {
71+ Name string
72+ Role Role
73+}
74+
75+func Load(path string) (*Config, error) {
76+ data, err := os.ReadFile(path)
77+ if err != nil {
78+ return nil, err
79+ }
80+ var cfg Config
81+ if err := yaml.Unmarshal(data, &cfg); err != nil {
82+ return nil, err
83+ }
84+ cfg.Root = filepath.Dir(path)
85+ cfg.applyDefaults()
86+ if err := cfg.Validate(); err != nil {
87+ return nil, err
88+ }
89+ return &cfg, nil
90+}
91+
92+func Default() *Config {
93+ cfg := &Config{Version: 1}
94+ cfg.applyDefaults()
95+ return cfg
96+}
97+
98+func (c *Config) applyDefaults() {
99+ if c.Version == 0 {
100+ c.Version = 1
101+ }
102+ if c.Rules == nil {
103+ c.Rules = map[string]Severity{}
104+ }
105+ for rule, sev := range DefaultRuleSeverities() {
106+ if _, ok := c.Rules[rule]; !ok {
107+ c.Rules[rule] = sev
108+ }
109+ }
110+ if c.Heuristics.BusinessLogicThreshold == 0 {
111+ c.Heuristics.BusinessLogicThreshold = 4
112+ }
113+ if len(c.Heuristics.BusinessKeywords) == 0 {
114+ c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"}
115+ }
116+ if len(c.Mocking.GeneratedMockNamePatterns) == 0 {
117+ c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
118+ }
119+}
120+
121+func DefaultRuleSeverities() map[string]Severity {
122+ return map[string]Severity{
123+ "no-adapter-imports-in-core": SeverityError,
124+ "no-infra-imports-in-usecase": SeverityError,
125+ "no-framework-types-in-core": SeverityError,
126+ "no-infra-types-in-ports": SeverityError,
127+ "no-adapter-to-adapter-imports": SeverityWarn,
128+ "suspicious-business-logic-in-adapter": SeverityWarn,
129+ "no-local-fakes-for-ports": SeverityWarn,
130+ "prefer-generated-mocks": SeverityWarn,
131+ }
132+}
133+
134+func (c *Config) Validate() error {
135+ if c.Version != 1 {
136+ return fmt.Errorf("version: unsupported value %d", c.Version)
137+ }
138+ for name, component := range c.Components {
139+ if name == "" {
140+ return errors.New("components: empty component name")
141+ }
142+ switch component.Role {
143+ case RoleCore, RoleUsecase, RolePorts, RoleAdapter, RoleEntrypoint, RoleIgnore:
144+ default:
145+ return fmt.Errorf("components.%s.role: unsupported role %q", name, component.Role)
146+ }
147+ if len(component.Paths) == 0 {
148+ return fmt.Errorf("components.%s.paths: must not be empty", name)
149+ }
150+ }
151+ for rule, severity := range c.Rules {
152+ switch severity {
153+ case SeverityOff, SeverityWarn, SeverityError:
154+ default:
155+ return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
156+ }
157+ }
158+ for i, allow := range c.Allow {
159+ if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
160+ return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
161+ }
162+ }
163+ return nil
164+}
165+
166+func (c *Config) Severity(rule string) Severity {
167+ if c == nil || c.Rules == nil {
168+ return SeverityOff
169+ }
170+ return c.Rules[rule]
171+}
172+
173+func (c *Config) RuleEnabled(rule string) bool {
174+ return c.Severity(rule) != SeverityOff
175+}
176+
177+func (c *Config) ComponentForPath(path string) (Match, bool) {
178+ type candidate struct {
179+ name string
180+ role Role
181+ spec int
182+ ord int
183+ }
184+ var candidates []candidate
185+ keys := make([]string, 0, len(c.Components))
186+ for key := range c.Components {
187+ keys = append(keys, key)
188+ }
189+ sort.Strings(keys)
190+ for ord, name := range keys {
191+ component := c.Components[name]
192+ for _, pattern := range component.Paths {
193+ if glob.Match(pattern, path) {
194+ candidates = append(candidates, candidate{name: name, role: component.Role, spec: glob.Specificity(pattern), ord: ord})
195+ }
196+ }
197+ }
198+ if len(candidates) == 0 {
199+ return Match{}, false
200+ }
201+ sort.SliceStable(candidates, func(i, j int) bool {
202+ if candidates[i].role == RoleIgnore && candidates[j].role != RoleIgnore {
203+ return true
204+ }
205+ if candidates[j].role == RoleIgnore && candidates[i].role != RoleIgnore {
206+ return false
207+ }
208+ if candidates[i].spec != candidates[j].spec {
209+ return candidates[i].spec > candidates[j].spec
210+ }
211+ return candidates[i].ord < candidates[j].ord
212+ })
213+ best := candidates[0]
214+ return Match{Name: best.name, Role: best.role}, true
215+}
216+
217+func (c *Config) IsAllowed(rule, path string) bool {
218+ for _, allow := range c.Allow {
219+ if allow.Rule == rule && glob.Match(allow.Path, path) {
220+ return true
221+ }
222+ }
223+ return false
224+}
225+
226+func DiscoverConfig(start string) (string, error) {
227+ start, err := filepath.Abs(start)
228+ if err != nil {
229+ return "", err
230+ }
231+ info, err := os.Stat(start)
232+ if err == nil && !info.IsDir() {
233+ start = filepath.Dir(start)
234+ }
235+ for dir := start; ; dir = filepath.Dir(dir) {
236+ candidate := filepath.Join(dir, ".hexcheck.yaml")
237+ if _, err := os.Stat(candidate); err == nil {
238+ return candidate, nil
239+ }
240+ if parent := filepath.Dir(dir); parent == dir {
241+ break
242+ }
243+ }
244+ return "", os.ErrNotExist
245+}
@@ -0,0 +1,245 @@
1+package config
2+
3+import (
4+ "errors"
5+ "fmt"
6+ "os"
7+ "path/filepath"
8+ "sort"
9+
10+ "github.com/bnema/hexcheck/internal/glob"
11+ "gopkg.in/yaml.v3"
12+)
13+
14+type Role string
15+
16+const (
17+ RoleCore Role = "core"
18+ RoleUsecase Role = "usecase"
19+ RolePorts Role = "ports"
20+ RoleAdapter Role = "adapter"
21+ RoleEntrypoint Role = "entrypoint"
22+ RoleIgnore Role = "ignore"
23+)
24+
25+type Severity string
26+
27+const (
28+ SeverityOff Severity = "off"
29+ SeverityWarn Severity = "warn"
30+ SeverityError Severity = "error"
31+)
32+
33+type Config struct {
34+ Version int `yaml:"version"`
35+ Components map[string]Component `yaml:"components"`
36+ Rules map[string]Severity `yaml:"rules"`
37+ ExternalTypes ExternalTypes `yaml:"externalTypes"`
38+ Heuristics Heuristics `yaml:"heuristics"`
39+ Mocking Mocking `yaml:"mocking"`
40+ Allow []Allow `yaml:"allow"`
41+ Root string `yaml:"-"`
42+}
43+
44+type Component struct {
45+ Role Role `yaml:"role"`
46+ Paths []string `yaml:"paths"`
47+}
48+
49+type ExternalTypes struct {
50+ FrameworkPackages []string `yaml:"frameworkPackages"`
51+ AdapterTypePackages []string `yaml:"adapterTypePackages"`
52+}
53+
54+type Heuristics struct {
55+ BusinessLogicThreshold int `yaml:"businessLogicThreshold"`
56+ BusinessKeywords []string `yaml:"businessKeywords"`
57+}
58+
59+type Mocking struct {
60+ GeneratedMockPaths []string `yaml:"generatedMockPaths"`
61+ GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"`
62+}
63+
64+type Allow struct {
65+ Rule string `yaml:"rule"`
66+ Path string `yaml:"path"`
67+ Reason string `yaml:"reason"`
68+}
69+
70+type Match struct {
71+ Name string
72+ Role Role
73+}
74+
75+func Load(path string) (*Config, error) {
76+ data, err := os.ReadFile(path)
77+ if err != nil {
78+ return nil, err
79+ }
80+ var cfg Config
81+ if err := yaml.Unmarshal(data, &cfg); err != nil {
82+ return nil, err
83+ }
84+ cfg.Root = filepath.Dir(path)
85+ cfg.applyDefaults()
86+ if err := cfg.Validate(); err != nil {
87+ return nil, err
88+ }
89+ return &cfg, nil
90+}
91+
92+func Default() *Config {
93+ cfg := &Config{Version: 1}
94+ cfg.applyDefaults()
95+ return cfg
96+}
97+
98+func (c *Config) applyDefaults() {
99+ if c.Version == 0 {
100+ c.Version = 1
101+ }
102+ if c.Rules == nil {
103+ c.Rules = map[string]Severity{}
104+ }
105+ for rule, sev := range DefaultRuleSeverities() {
106+ if _, ok := c.Rules[rule]; !ok {
107+ c.Rules[rule] = sev
108+ }
109+ }
110+ if c.Heuristics.BusinessLogicThreshold == 0 {
111+ c.Heuristics.BusinessLogicThreshold = 4
112+ }
113+ if len(c.Heuristics.BusinessKeywords) == 0 {
114+ c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"}
115+ }
116+ if len(c.Mocking.GeneratedMockNamePatterns) == 0 {
117+ c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
118+ }
119+}
120+
121+func DefaultRuleSeverities() map[string]Severity {
122+ return map[string]Severity{
123+ "no-adapter-imports-in-core": SeverityError,
124+ "no-infra-imports-in-usecase": SeverityError,
125+ "no-framework-types-in-core": SeverityError,
126+ "no-infra-types-in-ports": SeverityError,
127+ "no-adapter-to-adapter-imports": SeverityWarn,
128+ "suspicious-business-logic-in-adapter": SeverityWarn,
129+ "no-local-fakes-for-ports": SeverityWarn,
130+ "prefer-generated-mocks": SeverityWarn,
131+ }
132+}
133+
134+func (c *Config) Validate() error {
135+ if c.Version != 1 {
136+ return fmt.Errorf("version: unsupported value %d", c.Version)
137+ }
138+ for name, component := range c.Components {
139+ if name == "" {
140+ return errors.New("components: empty component name")
141+ }
142+ switch component.Role {
143+ case RoleCore, RoleUsecase, RolePorts, RoleAdapter, RoleEntrypoint, RoleIgnore:
144+ default:
145+ return fmt.Errorf("components.%s.role: unsupported role %q", name, component.Role)
146+ }
147+ if len(component.Paths) == 0 {
148+ return fmt.Errorf("components.%s.paths: must not be empty", name)
149+ }
150+ }
151+ for rule, severity := range c.Rules {
152+ switch severity {
153+ case SeverityOff, SeverityWarn, SeverityError:
154+ default:
155+ return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
156+ }
157+ }
158+ for i, allow := range c.Allow {
159+ if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
160+ return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
161+ }
162+ }
163+ return nil
164+}
165+
166+func (c *Config) Severity(rule string) Severity {
167+ if c == nil || c.Rules == nil {
168+ return SeverityOff
169+ }
170+ return c.Rules[rule]
171+}
172+
173+func (c *Config) RuleEnabled(rule string) bool {
174+ return c.Severity(rule) != SeverityOff
175+}
176+
177+func (c *Config) ComponentForPath(path string) (Match, bool) {
178+ type candidate struct {
179+ name string
180+ role Role
181+ spec int
182+ ord int
183+ }
184+ var candidates []candidate
185+ keys := make([]string, 0, len(c.Components))
186+ for key := range c.Components {
187+ keys = append(keys, key)
188+ }
189+ sort.Strings(keys)
190+ for ord, name := range keys {
191+ component := c.Components[name]
192+ for _, pattern := range component.Paths {
193+ if glob.Match(pattern, path) {
194+ candidates = append(candidates, candidate{name: name, role: component.Role, spec: glob.Specificity(pattern), ord: ord})
195+ }
196+ }
197+ }
198+ if len(candidates) == 0 {
199+ return Match{}, false
200+ }
201+ sort.SliceStable(candidates, func(i, j int) bool {
202+ if candidates[i].role == RoleIgnore && candidates[j].role != RoleIgnore {
203+ return true
204+ }
205+ if candidates[j].role == RoleIgnore && candidates[i].role != RoleIgnore {
206+ return false
207+ }
208+ if candidates[i].spec != candidates[j].spec {
209+ return candidates[i].spec > candidates[j].spec
210+ }
211+ return candidates[i].ord < candidates[j].ord
212+ })
213+ best := candidates[0]
214+ return Match{Name: best.name, Role: best.role}, true
215+}
216+
217+func (c *Config) IsAllowed(rule, path string) bool {
218+ for _, allow := range c.Allow {
219+ if allow.Rule == rule && glob.Match(allow.Path, path) {
220+ return true
221+ }
222+ }
223+ return false
224+}
225+
226+func DiscoverConfig(start string) (string, error) {
227+ start, err := filepath.Abs(start)
228+ if err != nil {
229+ return "", err
230+ }
231+ info, err := os.Stat(start)
232+ if err == nil && !info.IsDir() {
233+ start = filepath.Dir(start)
234+ }
235+ for dir := start; ; dir = filepath.Dir(dir) {
236+ candidate := filepath.Join(dir, ".hexcheck.yaml")
237+ if _, err := os.Stat(candidate); err == nil {
238+ return candidate, nil
239+ }
240+ if parent := filepath.Dir(dir); parent == dir {
241+ break
242+ }
243+ }
244+ return "", os.ErrNotExist
245+}
A config/config_test.go +74 -0
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLoad(t *testing.T) {
10+ dir := t.TempDir()
11+ path := filepath.Join(dir, ".hexcheck.yaml")
12+ data := []byte(`version: 1
13+components:
14+ core:
15+ paths: [internal/domain/**]
16+ role: core
17+ adapters:
18+ paths: [internal/infrastructure/**]
19+ role: adapter
20+rules:
21+ no-adapter-imports-in-core: error
22+allow:
23+ - rule: no-local-fakes-for-ports
24+ path: internal/foo_test.go
25+ 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")
42+ }
43+ if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") {
44+ t.Fatalf("allow entry not matched")
45+ }
46+}
47+
48+func TestComponentForPathPrecedence(t *testing.T) {
49+ cfg := &Config{Components: map[string]Component{
50+ "all": {Role: RoleCore, Paths: []string{"internal/**"}},
51+ "adapter": {Role: RoleAdapter, Paths: []string{"internal/infrastructure/**"}},
52+ "ignore": {Role: RoleIgnore, Paths: []string{"internal/infrastructure/generated/**"}},
53+ }}
54+ cfg.applyDefaults()
55+
56+ match, ok := cfg.ComponentForPath("internal/infrastructure/postgres/repo.go")
57+ if !ok || match.Role != RoleAdapter {
58+ t.Fatalf("match = %#v, %v", match, ok)
59+ }
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")
73+ }
74+}
@@ -0,0 +1,74 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
8+
9+func TestLoad(t *testing.T) {
10+ dir := t.TempDir()
11+ path := filepath.Join(dir, ".hexcheck.yaml")
12+ data := []byte(`version: 1
13+components:
14+ core:
15+ paths: [internal/domain/**]
16+ role: core
17+ adapters:
18+ paths: [internal/infrastructure/**]
19+ role: adapter
20+rules:
21+ no-adapter-imports-in-core: error
22+allow:
23+ - rule: no-local-fakes-for-ports
24+ path: internal/foo_test.go
25+ 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")
42+ }
43+ if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") {
44+ t.Fatalf("allow entry not matched")
45+ }
46+}
47+
48+func TestComponentForPathPrecedence(t *testing.T) {
49+ cfg := &Config{Components: map[string]Component{
50+ "all": {Role: RoleCore, Paths: []string{"internal/**"}},
51+ "adapter": {Role: RoleAdapter, Paths: []string{"internal/infrastructure/**"}},
52+ "ignore": {Role: RoleIgnore, Paths: []string{"internal/infrastructure/generated/**"}},
53+ }}
54+ cfg.applyDefaults()
55+
56+ match, ok := cfg.ComponentForPath("internal/infrastructure/postgres/repo.go")
57+ if !ok || match.Role != RoleAdapter {
58+ t.Fatalf("match = %#v, %v", match, ok)
59+ }
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")
73+ }
74+}
M go.mod +8 -1
@@ -1,3 +1,10 @@
11 module github.com/bnema/hexcheck
22
3-go 1.24
3+go 1.25.0
4+
5+require (
6+ golang.org/x/mod v0.36.0 // indirect
7+ golang.org/x/sync v0.20.0 // indirect
8+ golang.org/x/tools v0.45.0 // indirect
9+ gopkg.in/yaml.v3 v3.0.1 // indirect
10+)
@@ -1,3 +1,10 @@
1 module github.com/bnema/hexcheck1 module github.com/bnema/hexcheck
2 2
3-go 1.243+go 1.25.0
4+
5+require (
6+ golang.org/x/mod v0.36.0 // indirect
7+ golang.org/x/sync v0.20.0 // indirect
8+ golang.org/x/tools v0.45.0 // indirect
9+ gopkg.in/yaml.v3 v3.0.1 // indirect
10+)
A go.sum +9 -0 generated file: collapsed
A internal/glob/glob.go +55 -0
@@ -0,0 +1,55 @@
1+package glob
2+
3+import (
4+ "path"
5+ "strings"
6+)
7+
8+func Normalize(p string) string {
9+ p = strings.ReplaceAll(p, "\\", "/")
10+ p = strings.TrimPrefix(p, "./")
11+ return strings.Trim(p, "/")
12+}
13+
14+func Match(pattern, name string) bool {
15+ pattern = Normalize(pattern)
16+ name = Normalize(name)
17+ if pattern == "" {
18+ return name == ""
19+ }
20+ return matchParts(strings.Split(pattern, "/"), strings.Split(name, "/"))
21+}
22+
23+func matchParts(pattern, name []string) bool {
24+ if len(pattern) == 0 {
25+ return len(name) == 0
26+ }
27+ if pattern[0] == "**" {
28+ if len(pattern) == 1 {
29+ return true
30+ }
31+ for i := 0; i <= len(name); i++ {
32+ if matchParts(pattern[1:], name[i:]) {
33+ return true
34+ }
35+ }
36+ return false
37+ }
38+ if len(name) == 0 {
39+ return false
40+ }
41+ ok, err := path.Match(pattern[0], name[0])
42+ if err != nil || !ok {
43+ return false
44+ }
45+ return matchParts(pattern[1:], name[1:])
46+}
47+
48+func Specificity(pattern string) int {
49+ pattern = Normalize(pattern)
50+ cut := strings.IndexAny(pattern, "*?[{")
51+ if cut == -1 {
52+ return len(pattern)
53+ }
54+ return cut
55+}
@@ -0,0 +1,55 @@
1+package glob
2+
3+import (
4+ "path"
5+ "strings"
6+)
7+
8+func Normalize(p string) string {
9+ p = strings.ReplaceAll(p, "\\", "/")
10+ p = strings.TrimPrefix(p, "./")
11+ return strings.Trim(p, "/")
12+}
13+
14+func Match(pattern, name string) bool {
15+ pattern = Normalize(pattern)
16+ name = Normalize(name)
17+ if pattern == "" {
18+ return name == ""
19+ }
20+ return matchParts(strings.Split(pattern, "/"), strings.Split(name, "/"))
21+}
22+
23+func matchParts(pattern, name []string) bool {
24+ if len(pattern) == 0 {
25+ return len(name) == 0
26+ }
27+ if pattern[0] == "**" {
28+ if len(pattern) == 1 {
29+ return true
30+ }
31+ for i := 0; i <= len(name); i++ {
32+ if matchParts(pattern[1:], name[i:]) {
33+ return true
34+ }
35+ }
36+ return false
37+ }
38+ if len(name) == 0 {
39+ return false
40+ }
41+ ok, err := path.Match(pattern[0], name[0])
42+ if err != nil || !ok {
43+ return false
44+ }
45+ return matchParts(pattern[1:], name[1:])
46+}
47+
48+func Specificity(pattern string) int {
49+ pattern = Normalize(pattern)
50+ cut := strings.IndexAny(pattern, "*?[{")
51+ if cut == -1 {
52+ return len(pattern)
53+ }
54+ return cut
55+}
A internal/glob/glob_test.go +24 -0
@@ -0,0 +1,24 @@
1+package glob
2+
3+import "testing"
4+
5+func TestMatch(t *testing.T) {
6+ tests := []struct {
7+ pattern string
8+ name string
9+ want bool
10+ }{
11+ {"internal/domain/**", "internal/domain/user.go", true},
12+ {"internal/domain/**", "internal/domain/entity/user.go", true},
13+ {"cmd/**", "cmd/hexcheck/main.go", true},
14+ {"cmd/**", "internal/cmd/main.go", false},
15+ {"internal/*/port/**", "internal/application/port/user.go", true},
16+ {"internal/*/port/**", "internal/application/usecase/user.go", false},
17+ {"internal/foo_test.go", "internal/foo_test.go", true},
18+ }
19+ for _, tt := range tests {
20+ if got := Match(tt.pattern, tt.name); got != tt.want {
21+ t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)
22+ }
23+ }
24+}
@@ -0,0 +1,24 @@
1+package glob
2+
3+import "testing"
4+
5+func TestMatch(t *testing.T) {
6+ tests := []struct {
7+ pattern string
8+ name string
9+ want bool
10+ }{
11+ {"internal/domain/**", "internal/domain/user.go", true},
12+ {"internal/domain/**", "internal/domain/entity/user.go", true},
13+ {"cmd/**", "cmd/hexcheck/main.go", true},
14+ {"cmd/**", "internal/cmd/main.go", false},
15+ {"internal/*/port/**", "internal/application/port/user.go", true},
16+ {"internal/*/port/**", "internal/application/usecase/user.go", false},
17+ {"internal/foo_test.go", "internal/foo_test.go", true},
18+ }
19+ for _, tt := range tests {
20+ if got := Match(tt.pattern, tt.name); got != tt.want {
21+ t.Fatalf("Match(%q, %q) = %v, want %v", tt.pattern, tt.name, got, tt.want)
22+ }
23+ }
24+}

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