feat(analyzer): enforce boundary rules
- commit
- 3e9690d1753c43233070e732d5e33d0c0f932ef9
- author
- brice · · 2026-05-30 04:57 UTC
- parent
- 996ed79
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 | |
| 2 | 2 | ||
| 3 | 3 | test: | |
| 4 | 4 | go test ./... | |
| @@ -11,5 +11,9 @@ tidy: | |||
| 11 | 11 | ||
| 12 | 12 | check: tidy test | |
| 13 | 13 | ||
| 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-dumber | 1 | + | .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 test | 12 | check: tidy test | ||
| 13 | 13 | ||||
| 14 | - | smoke-dumber: | 14 | + | smoke-local: |
| 15 | - | HEXCHECK_DUMBER_PATH=/home/brice/dev/projects/dumber go test ./test/smoke | 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 | |||
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 | |||
| 10 | 10 | - catch deterministic boundary violations in CI | |
| 11 | 11 | - warn about suspicious business logic in adapters and entrypoints | |
| 12 | 12 | - 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 | |
| 14 | 14 | ||
| 15 | 15 | ## Quick example | |
| 16 | 16 | ||
| @@ -94,5 +94,5 @@ linters: | |||
| 94 | 94 | ```bash | |
| 95 | 95 | make test | |
| 96 | 96 | 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 | |
| 98 | 98 | ``` | |
| @@ -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 CI | 10 | - catch deterministic boundary violations in CI | ||
| 11 | - warn about suspicious business logic in adapters and entrypoints | 11 | - warn about suspicious business logic in adapters and entrypoints | ||
| 12 | - warn when tests bypass generated interface mocks with local fakes or real adapters | 12 | - 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 |
| 14 | 14 | ||||
| 15 | ## Quick example | 15 | ## Quick example | ||
| 16 | 16 | ||||
| @@ -94,5 +94,5 @@ linters: | |||||
| 94 | ```bash | 94 | ```bash | ||
| 95 | make test | 95 | make test | ||
| 96 | make check | 96 | 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 |
| 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 @@ | |||
| 1 | 1 | module github.com/bnema/hexcheck | |
| 2 | 2 | ||
| 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/hexcheck | 1 | module github.com/bnema/hexcheck | ||
| 2 | 2 | ||||
| 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 | + | ) | |||
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 | + | } | |||