fix(analyzer): tune heuristics and mock detection
- commit
- 4de0705ecb463cf07158255873d67a5c9720f963
- author
- brice · · 2026-05-30 05:49 UTC
- parent
- cd0745f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +420 -25
M analyzer/analyzer.go +34 -2
| @@ -55,9 +55,24 @@ func New(opts Options) *analysis.Analyzer { | |||
| 55 | 55 | ||
| 56 | 56 | func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) { | |
| 57 | 57 | if opts.Config != nil { | |
| 58 | - | return opts.Config, opts.Config.Root, nil | |
| 58 | + | cfg := cloneConfig(opts.Config) | |
| 59 | + | if cfg.Root != "" { | |
| 60 | + | root, err := filepath.Abs(cfg.Root) | |
| 61 | + | if err != nil { | |
| 62 | + | return nil, "", err | |
| 63 | + | } | |
| 64 | + | cfg.Root = root | |
| 65 | + | } | |
| 66 | + | return cfg, cfg.Root, nil | |
| 59 | 67 | } | |
| 60 | 68 | root := firstNonEmpty(opts.Root, rootFlag) | |
| 69 | + | if root != "" { | |
| 70 | + | absRoot, err := filepath.Abs(root) | |
| 71 | + | if err != nil { | |
| 72 | + | return nil, "", err | |
| 73 | + | } | |
| 74 | + | root = absRoot | |
| 75 | + | } | |
| 61 | 76 | configPath := firstNonEmpty(opts.ConfigPath, configPathFlag) | |
| 62 | 77 | if configPath == "" { | |
| 63 | 78 | start := root | |
| @@ -129,8 +144,12 @@ func (r runner) run() { | |||
| 129 | 144 | } | |
| 130 | 145 | for _, file := range r.pass.Files { | |
| 131 | 146 | filePath := r.filePath(file) | |
| 147 | + | if isGeneratedFile(file) { | |
| 148 | + | continue | |
| 149 | + | } | |
| 132 | 150 | r.checkImports(file, filePath, current) | |
| 133 | 151 | r.checkTypeLeaks(file, filePath, current) | |
| 152 | + | r.checkMissingMocks(file, filePath, current) | |
| 134 | 153 | r.checkBusinessLogic(file, filePath, current) | |
| 135 | 154 | r.checkMocks(file, filePath, current) | |
| 136 | 155 | } | |
| @@ -237,12 +256,25 @@ func (r runner) matchesExternal(pkg string) bool { | |||
| 237 | 256 | } | |
| 238 | 257 | ||
| 239 | 258 | func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) { | |
| 240 | - | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) { | |
| 259 | + | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) { | |
| 241 | 260 | return | |
| 242 | 261 | } | |
| 243 | 262 | r.pass.Reportf(pos, rule+": "+format, args...) | |
| 244 | 263 | } | |
| 245 | 264 | ||
| 265 | + | func (r runner) isExcluded(rule, filePath string) bool { | |
| 266 | + | setting, ok := r.cfg.RuleSettings[rule] | |
| 267 | + | if !ok { | |
| 268 | + | return false | |
| 269 | + | } | |
| 270 | + | for _, pattern := range setting.ExcludePaths { | |
| 271 | + | if glob.Match(pattern, filePath) { | |
| 272 | + | return true | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | return false | |
| 276 | + | } | |
| 277 | + | ||
| 246 | 278 | func (r runner) relImportPath(importPath string) string { | |
| 247 | 279 | if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") { | |
| 248 | 280 | return strings.TrimPrefix(importPath, r.modulePath+"/") | |
| @@ -55,9 +55,24 @@ func New(opts Options) *analysis.Analyzer { | |||||
| 55 | 55 | ||||
| 56 | func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) { | 56 | func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) { | ||
| 57 | if opts.Config != nil { | 57 | if opts.Config != nil { | ||
| 58 | - | return opts.Config, opts.Config.Root, nil | 58 | + | cfg := cloneConfig(opts.Config) |
| 59 | + | if cfg.Root != "" { | |||
| 60 | + | root, err := filepath.Abs(cfg.Root) | |||
| 61 | + | if err != nil { | |||
| 62 | + | return nil, "", err | |||
| 63 | + | } | |||
| 64 | + | cfg.Root = root | |||
| 65 | + | } | |||
| 66 | + | return cfg, cfg.Root, nil | |||
| 59 | } | 67 | } | ||
| 60 | root := firstNonEmpty(opts.Root, rootFlag) | 68 | root := firstNonEmpty(opts.Root, rootFlag) | ||
| 69 | + | if root != "" { | |||
| 70 | + | absRoot, err := filepath.Abs(root) | |||
| 71 | + | if err != nil { | |||
| 72 | + | return nil, "", err | |||
| 73 | + | } | |||
| 74 | + | root = absRoot | |||
| 75 | + | } | |||
| 61 | configPath := firstNonEmpty(opts.ConfigPath, configPathFlag) | 76 | configPath := firstNonEmpty(opts.ConfigPath, configPathFlag) | ||
| 62 | if configPath == "" { | 77 | if configPath == "" { | ||
| 63 | start := root | 78 | start := root | ||
| @@ -129,8 +144,12 @@ func (r runner) run() { | |||||
| 129 | } | 144 | } | ||
| 130 | for _, file := range r.pass.Files { | 145 | for _, file := range r.pass.Files { | ||
| 131 | filePath := r.filePath(file) | 146 | filePath := r.filePath(file) | ||
| 147 | + | if isGeneratedFile(file) { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 132 | r.checkImports(file, filePath, current) | 150 | r.checkImports(file, filePath, current) | ||
| 133 | r.checkTypeLeaks(file, filePath, current) | 151 | r.checkTypeLeaks(file, filePath, current) | ||
| 152 | + | r.checkMissingMocks(file, filePath, current) | |||
| 134 | r.checkBusinessLogic(file, filePath, current) | 153 | r.checkBusinessLogic(file, filePath, current) | ||
| 135 | r.checkMocks(file, filePath, current) | 154 | r.checkMocks(file, filePath, current) | ||
| 136 | } | 155 | } | ||
| @@ -237,12 +256,25 @@ func (r runner) matchesExternal(pkg string) bool { | |||||
| 237 | } | 256 | } | ||
| 238 | 257 | ||||
| 239 | func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) { | 258 | func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) { | ||
| 240 | - | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) { | 259 | + | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) { |
| 241 | return | 260 | return | ||
| 242 | } | 261 | } | ||
| 243 | r.pass.Reportf(pos, rule+": "+format, args...) | 262 | r.pass.Reportf(pos, rule+": "+format, args...) | ||
| 244 | } | 263 | } | ||
| 245 | 264 | ||||
| 265 | + | func (r runner) isExcluded(rule, filePath string) bool { | |||
| 266 | + | setting, ok := r.cfg.RuleSettings[rule] | |||
| 267 | + | if !ok { | |||
| 268 | + | return false | |||
| 269 | + | } | |||
| 270 | + | for _, pattern := range setting.ExcludePaths { | |||
| 271 | + | if glob.Match(pattern, filePath) { | |||
| 272 | + | return true | |||
| 273 | + | } | |||
| 274 | + | } | |||
| 275 | + | return false | |||
| 276 | + | } | |||
| 277 | + | ||||
| 246 | func (r runner) relImportPath(importPath string) string { | 278 | func (r runner) relImportPath(importPath string) string { | ||
| 247 | if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") { | 279 | if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") { | ||
| 248 | return strings.TrimPrefix(importPath, r.modulePath+"/") | 280 | return strings.TrimPrefix(importPath, r.modulePath+"/") | ||
M analyzer/analyzer_test.go +5 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "path/filepath" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | "github.com/bnema/hexcheck/config" | |
| @@ -37,6 +38,7 @@ func TestAnalyzer(t *testing.T) { | |||
| 37 | 38 | ||
| 38 | 39 | func testConfig() *config.Config { | |
| 39 | 40 | cfg := &config.Config{ | |
| 41 | + | Root: filepath.Join(analysistest.TestData(), "src/example.com/project"), | |
| 40 | 42 | Version: 1, | |
| 41 | 43 | Components: map[string]config.Component{ | |
| 42 | 44 | "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}}, | |
| @@ -49,6 +51,9 @@ func testConfig() *config.Config { | |||
| 49 | 51 | ExternalTypes: config.ExternalTypes{ | |
| 50 | 52 | FrameworkPackages: []string{"example.com/framework"}, | |
| 51 | 53 | }, | |
| 54 | + | Mocking: config.Mocking{ | |
| 55 | + | GeneratedMockPaths: []string{"internal/application/port/mocks/**"}, | |
| 56 | + | }, | |
| 52 | 57 | Allow: []config.Allow{ | |
| 53 | 58 | {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"}, | |
| 54 | 59 | }, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package analyzer | 1 | package analyzer | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "path/filepath" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | "github.com/bnema/hexcheck/config" | 7 | "github.com/bnema/hexcheck/config" | ||
| @@ -37,6 +38,7 @@ func TestAnalyzer(t *testing.T) { | |||||
| 37 | 38 | ||||
| 38 | func testConfig() *config.Config { | 39 | func testConfig() *config.Config { | ||
| 39 | cfg := &config.Config{ | 40 | cfg := &config.Config{ | ||
| 41 | + | Root: filepath.Join(analysistest.TestData(), "src/example.com/project"), | |||
| 40 | Version: 1, | 42 | Version: 1, | ||
| 41 | Components: map[string]config.Component{ | 43 | Components: map[string]config.Component{ | ||
| 42 | "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}}, | 44 | "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}}, | ||
| @@ -49,6 +51,9 @@ func testConfig() *config.Config { | |||||
| 49 | ExternalTypes: config.ExternalTypes{ | 51 | ExternalTypes: config.ExternalTypes{ | ||
| 50 | FrameworkPackages: []string{"example.com/framework"}, | 52 | FrameworkPackages: []string{"example.com/framework"}, | ||
| 51 | }, | 53 | }, | ||
| 54 | + | Mocking: config.Mocking{ | |||
| 55 | + | GeneratedMockPaths: []string{"internal/application/port/mocks/**"}, | |||
| 56 | + | }, | |||
| 52 | Allow: []config.Allow{ | 57 | Allow: []config.Allow{ | ||
| 53 | {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"}, | 58 | {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"}, | ||
| 54 | }, | 59 | }, | ||
M analyzer/business_logic.go +26 -1
| @@ -11,6 +11,9 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 11 | 11 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | |
| 12 | 12 | return | |
| 13 | 13 | } | |
| 14 | + | if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") { | |
| 15 | + | return | |
| 16 | + | } | |
| 14 | 17 | ||
| 15 | 18 | threshold := r.cfg.Heuristics.BusinessLogicThreshold | |
| 16 | 19 | for _, decl := range file.Decls { | |
| @@ -21,7 +24,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 21 | 24 | ||
| 22 | 25 | score := 0 | |
| 23 | 26 | for _, keyword := range r.cfg.Heuristics.BusinessKeywords { | |
| 24 | - | if strings.Contains(fn.Name.Name, keyword) { | |
| 27 | + | if containsWord(fn.Name.Name, keyword) { | |
| 25 | 28 | score += 2 | |
| 26 | 29 | } | |
| 27 | 30 | } | |
| @@ -39,3 +42,25 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 39 | 42 | } | |
| 40 | 43 | } | |
| 41 | 44 | } | |
| 45 | + | ||
| 46 | + | func containsWord(name, word string) bool { | |
| 47 | + | if word == "" { | |
| 48 | + | return false | |
| 49 | + | } | |
| 50 | + | for i := 0; i <= len(name)-len(word); i++ { | |
| 51 | + | if !strings.EqualFold(name[i:i+len(word)], word) { | |
| 52 | + | continue | |
| 53 | + | } | |
| 54 | + | beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i])) | |
| 55 | + | after := i + len(word) | |
| 56 | + | afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after])) | |
| 57 | + | if beforeOK && afterOK { | |
| 58 | + | return true | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func isBoundaryRune(prev, next rune) bool { | |
| 65 | + | return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z') | |
| 66 | + | } | |
| @@ -11,6 +11,9 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 11 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | 11 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | ||
| 12 | return | 12 | return | ||
| 13 | } | 13 | } | ||
| 14 | + | if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") { | |||
| 15 | + | return | |||
| 16 | + | } | |||
| 14 | 17 | ||||
| 15 | threshold := r.cfg.Heuristics.BusinessLogicThreshold | 18 | threshold := r.cfg.Heuristics.BusinessLogicThreshold | ||
| 16 | for _, decl := range file.Decls { | 19 | for _, decl := range file.Decls { | ||
| @@ -21,7 +24,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 21 | 24 | ||||
| 22 | score := 0 | 25 | score := 0 | ||
| 23 | for _, keyword := range r.cfg.Heuristics.BusinessKeywords { | 26 | for _, keyword := range r.cfg.Heuristics.BusinessKeywords { | ||
| 24 | - | if strings.Contains(fn.Name.Name, keyword) { | 27 | + | if containsWord(fn.Name.Name, keyword) { |
| 25 | score += 2 | 28 | score += 2 | ||
| 26 | } | 29 | } | ||
| 27 | } | 30 | } | ||
| @@ -39,3 +42,25 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 39 | } | 42 | } | ||
| 40 | } | 43 | } | ||
| 41 | } | 44 | } | ||
| 45 | + | ||||
| 46 | + | func containsWord(name, word string) bool { | |||
| 47 | + | if word == "" { | |||
| 48 | + | return false | |||
| 49 | + | } | |||
| 50 | + | for i := 0; i <= len(name)-len(word); i++ { | |||
| 51 | + | if !strings.EqualFold(name[i:i+len(word)], word) { | |||
| 52 | + | continue | |||
| 53 | + | } | |||
| 54 | + | beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i])) | |||
| 55 | + | after := i + len(word) | |||
| 56 | + | afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after])) | |||
| 57 | + | if beforeOK && afterOK { | |||
| 58 | + | return true | |||
| 59 | + | } | |||
| 60 | + | } | |||
| 61 | + | return false | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func isBoundaryRune(prev, next rune) bool { | |||
| 65 | + | return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z') | |||
| 66 | + | } | |||
A analyzer/config_clone.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package analyzer | |
| 2 | + | ||
| 3 | + | import "github.com/bnema/hexcheck/config" | |
| 4 | + | ||
| 5 | + | func cloneConfig(in *config.Config) *config.Config { | |
| 6 | + | if in == nil { | |
| 7 | + | return nil | |
| 8 | + | } | |
| 9 | + | out := *in | |
| 10 | + | out.Components = make(map[string]config.Component, len(in.Components)) | |
| 11 | + | for name, component := range in.Components { | |
| 12 | + | component.Paths = append([]string(nil), component.Paths...) | |
| 13 | + | out.Components[name] = component | |
| 14 | + | } | |
| 15 | + | out.Rules = make(map[string]config.Severity, len(in.Rules)) | |
| 16 | + | for rule, severity := range in.Rules { | |
| 17 | + | out.Rules[rule] = severity | |
| 18 | + | } | |
| 19 | + | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | |
| 20 | + | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | |
| 21 | + | out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...) | |
| 22 | + | if in.Heuristics.ExcludeTestFiles != nil { | |
| 23 | + | value := *in.Heuristics.ExcludeTestFiles | |
| 24 | + | out.Heuristics.ExcludeTestFiles = &value | |
| 25 | + | } | |
| 26 | + | out.Mocking.GeneratedMockPaths = append([]string(nil), in.Mocking.GeneratedMockPaths...) | |
| 27 | + | out.Mocking.GeneratedMockNamePatterns = append([]string(nil), in.Mocking.GeneratedMockNamePatterns...) | |
| 28 | + | out.RuleSettings = make(map[string]config.RuleSetting, len(in.RuleSettings)) | |
| 29 | + | for rule, setting := range in.RuleSettings { | |
| 30 | + | setting.ExcludePaths = append([]string(nil), setting.ExcludePaths...) | |
| 31 | + | out.RuleSettings[rule] = setting | |
| 32 | + | } | |
| 33 | + | out.Allow = append([]config.Allow(nil), in.Allow...) | |
| 34 | + | return &out | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | package analyzer | |||
| 2 | + | ||||
| 3 | + | import "github.com/bnema/hexcheck/config" | |||
| 4 | + | ||||
| 5 | + | func cloneConfig(in *config.Config) *config.Config { | |||
| 6 | + | if in == nil { | |||
| 7 | + | return nil | |||
| 8 | + | } | |||
| 9 | + | out := *in | |||
| 10 | + | out.Components = make(map[string]config.Component, len(in.Components)) | |||
| 11 | + | for name, component := range in.Components { | |||
| 12 | + | component.Paths = append([]string(nil), component.Paths...) | |||
| 13 | + | out.Components[name] = component | |||
| 14 | + | } | |||
| 15 | + | out.Rules = make(map[string]config.Severity, len(in.Rules)) | |||
| 16 | + | for rule, severity := range in.Rules { | |||
| 17 | + | out.Rules[rule] = severity | |||
| 18 | + | } | |||
| 19 | + | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | |||
| 20 | + | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | |||
| 21 | + | out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...) | |||
| 22 | + | if in.Heuristics.ExcludeTestFiles != nil { | |||
| 23 | + | value := *in.Heuristics.ExcludeTestFiles | |||
| 24 | + | out.Heuristics.ExcludeTestFiles = &value | |||
| 25 | + | } | |||
| 26 | + | out.Mocking.GeneratedMockPaths = append([]string(nil), in.Mocking.GeneratedMockPaths...) | |||
| 27 | + | out.Mocking.GeneratedMockNamePatterns = append([]string(nil), in.Mocking.GeneratedMockNamePatterns...) | |||
| 28 | + | out.RuleSettings = make(map[string]config.RuleSetting, len(in.RuleSettings)) | |||
| 29 | + | for rule, setting := range in.RuleSettings { | |||
| 30 | + | setting.ExcludePaths = append([]string(nil), setting.ExcludePaths...) | |||
| 31 | + | out.RuleSettings[rule] = setting | |||
| 32 | + | } | |||
| 33 | + | out.Allow = append([]config.Allow(nil), in.Allow...) | |||
| 34 | + | return &out | |||
| 35 | + | } | |||
A analyzer/generated.go +19 -0
| @@ -0,0 +1,19 @@ | |||
| 1 | + | package analyzer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "go/ast" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func isGeneratedFile(file *ast.File) bool { | |
| 9 | + | for _, group := range file.Comments { | |
| 10 | + | for _, comment := range group.List { | |
| 11 | + | text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) | |
| 12 | + | text = strings.TrimSpace(strings.TrimPrefix(text, "/*")) | |
| 13 | + | if strings.HasPrefix(text, "Code generated ") && strings.Contains(text, "DO NOT EDIT") { | |
| 14 | + | return true | |
| 15 | + | } | |
| 16 | + | } | |
| 17 | + | } | |
| 18 | + | return false | |
| 19 | + | } | |
| @@ -0,0 +1,19 @@ | |||||
| 1 | + | package analyzer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "go/ast" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func isGeneratedFile(file *ast.File) bool { | |||
| 9 | + | for _, group := range file.Comments { | |||
| 10 | + | for _, comment := range group.List { | |||
| 11 | + | text := strings.TrimSpace(strings.TrimPrefix(comment.Text, "//")) | |||
| 12 | + | text = strings.TrimSpace(strings.TrimPrefix(text, "/*")) | |||
| 13 | + | if strings.HasPrefix(text, "Code generated ") && strings.Contains(text, "DO NOT EDIT") { | |||
| 14 | + | return true | |||
| 15 | + | } | |||
| 16 | + | } | |||
| 17 | + | } | |||
| 18 | + | return false | |||
| 19 | + | } | |||
M analyzer/mocks.go +135 -6
| @@ -2,13 +2,44 @@ package analyzer | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 5 | 7 | "go/types" | |
| 8 | + | "os" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sort" | |
| 6 | 11 | "strconv" | |
| 7 | 12 | "strings" | |
| 13 | + | "sync" | |
| 8 | 14 | ||
| 9 | 15 | "github.com/bnema/hexcheck/config" | |
| 16 | + | "github.com/bnema/hexcheck/internal/glob" | |
| 10 | 17 | ) | |
| 11 | 18 | ||
| 19 | + | type portInterface struct { | |
| 20 | + | name string | |
| 21 | + | iface *types.Interface | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (r runner) checkMissingMocks(file *ast.File, filePath string, current config.Match) { | |
| 25 | + | if current.Role != config.RolePorts { | |
| 26 | + | return | |
| 27 | + | } | |
| 28 | + | ast.Inspect(file, func(n ast.Node) bool { | |
| 29 | + | ts, ok := n.(*ast.TypeSpec) | |
| 30 | + | if !ok { | |
| 31 | + | return true | |
| 32 | + | } | |
| 33 | + | if _, ok := ts.Type.(*ast.InterfaceType); !ok { | |
| 34 | + | return true | |
| 35 | + | } | |
| 36 | + | if !r.generatedMockExists(ts.Name.Name) { | |
| 37 | + | r.report(ts.Name.Pos(), "missing-generated-mock-for-port", filePath, "port interface %s has no generated mock matching configured paths and name patterns", ts.Name.Name) | |
| 38 | + | } | |
| 39 | + | return true | |
| 40 | + | }) | |
| 41 | + | } | |
| 42 | + | ||
| 12 | 43 | func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) { | |
| 13 | 44 | if !strings.HasSuffix(filePath, "_test.go") { | |
| 14 | 45 | return | |
| @@ -41,9 +72,12 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||
| 41 | 72 | if !ok || obj.Type() == nil { | |
| 42 | 73 | return true | |
| 43 | 74 | } | |
| 44 | - | for _, iface := range portInterfaces { | |
| 45 | - | if types.Implements(obj.Type(), iface) || types.Implements(types.NewPointer(obj.Type()), iface) { | |
| 46 | - | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s; prefer generated mocks when available", ts.Name.Name, iface.String()) | |
| 75 | + | for _, port := range portInterfaces { | |
| 76 | + | if !r.generatedMockExists(port.name) { | |
| 77 | + | continue | |
| 78 | + | } | |
| 79 | + | if types.Implements(obj.Type(), port.iface) || types.Implements(types.NewPointer(obj.Type()), port.iface) { | |
| 80 | + | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s with a generated mock available", ts.Name.Name, port.name) | |
| 47 | 81 | break | |
| 48 | 82 | } | |
| 49 | 83 | } | |
| @@ -51,8 +85,8 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||
| 51 | 85 | }) | |
| 52 | 86 | } | |
| 53 | 87 | ||
| 54 | - | func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |
| 55 | - | var out []*types.Interface | |
| 88 | + | func (r runner) importedPortInterfaces(file *ast.File) []portInterface { | |
| 89 | + | var out []portInterface | |
| 56 | 90 | for _, imp := range file.Imports { | |
| 57 | 91 | pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName) | |
| 58 | 92 | if !ok || pkgName.Imported() == nil { | |
| @@ -69,13 +103,108 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |||
| 69 | 103 | } | |
| 70 | 104 | iface, ok := obj.Type().Underlying().(*types.Interface) | |
| 71 | 105 | if ok { | |
| 72 | - | out = append(out, iface.Complete()) | |
| 106 | + | out = append(out, portInterface{name: name, iface: iface.Complete()}) | |
| 73 | 107 | } | |
| 74 | 108 | } | |
| 75 | 109 | } | |
| 76 | 110 | return out | |
| 77 | 111 | } | |
| 78 | 112 | ||
| 113 | + | var mockIndexCache sync.Map | |
| 114 | + | ||
| 115 | + | func (r runner) generatedMockExists(interfaceName string) bool { | |
| 116 | + | if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 { | |
| 117 | + | return false | |
| 118 | + | } | |
| 119 | + | index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths) | |
| 120 | + | for _, name := range r.expectedMockNames(interfaceName) { | |
| 121 | + | if index[name] { | |
| 122 | + | return true | |
| 123 | + | } | |
| 124 | + | } | |
| 125 | + | return false | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func cachedMockIndex(root string, patterns []string) map[string]bool { | |
| 129 | + | key := mockIndexKey(root, patterns) | |
| 130 | + | if cached, ok := mockIndexCache.Load(key); ok { | |
| 131 | + | return cached.(map[string]bool) | |
| 132 | + | } | |
| 133 | + | index := buildMockIndex(root, patterns) | |
| 134 | + | actual, _ := mockIndexCache.LoadOrStore(key, index) | |
| 135 | + | return actual.(map[string]bool) | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | func mockIndexKey(root string, patterns []string) string { | |
| 139 | + | patterns = append([]string(nil), patterns...) | |
| 140 | + | sort.Strings(patterns) | |
| 141 | + | return root + "\x00" + strings.Join(patterns, "\x00") | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | func buildMockIndex(root string, patterns []string) map[string]bool { | |
| 145 | + | index := map[string]bool{} | |
| 146 | + | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 147 | + | if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") { | |
| 148 | + | return nil | |
| 149 | + | } | |
| 150 | + | rel, err := filepath.Rel(root, path) | |
| 151 | + | if err != nil { | |
| 152 | + | return nil | |
| 153 | + | } | |
| 154 | + | rel = filepath.ToSlash(rel) | |
| 155 | + | if !matchesAnyGlob(patterns, rel) { | |
| 156 | + | return nil | |
| 157 | + | } | |
| 158 | + | for _, name := range declaredTypes(path) { | |
| 159 | + | index[name] = true | |
| 160 | + | } | |
| 161 | + | return nil | |
| 162 | + | }) | |
| 163 | + | return index | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func declaredTypes(path string) []string { | |
| 167 | + | file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) | |
| 168 | + | if err != nil { | |
| 169 | + | return nil | |
| 170 | + | } | |
| 171 | + | var names []string | |
| 172 | + | for _, decl := range file.Decls { | |
| 173 | + | gen, ok := decl.(*ast.GenDecl) | |
| 174 | + | if !ok || gen.Tok != token.TYPE { | |
| 175 | + | continue | |
| 176 | + | } | |
| 177 | + | for _, spec := range gen.Specs { | |
| 178 | + | ts, ok := spec.(*ast.TypeSpec) | |
| 179 | + | if ok { | |
| 180 | + | names = append(names, ts.Name.Name) | |
| 181 | + | } | |
| 182 | + | } | |
| 183 | + | } | |
| 184 | + | return names | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | func (r runner) expectedMockNames(interfaceName string) []string { | |
| 188 | + | patterns := r.cfg.Mocking.GeneratedMockNamePatterns | |
| 189 | + | if len(patterns) == 0 { | |
| 190 | + | patterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"} | |
| 191 | + | } | |
| 192 | + | out := make([]string, 0, len(patterns)) | |
| 193 | + | for _, pattern := range patterns { | |
| 194 | + | out = append(out, strings.ReplaceAll(pattern, "{{Interface}}", interfaceName)) | |
| 195 | + | } | |
| 196 | + | return out | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | func matchesAnyGlob(patterns []string, path string) bool { | |
| 200 | + | for _, pattern := range patterns { | |
| 201 | + | if glob.Match(pattern, path) { | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | } | |
| 205 | + | return false | |
| 206 | + | } | |
| 207 | + | ||
| 79 | 208 | func isTestDoubleName(name string) bool { | |
| 80 | 209 | return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub") | |
| 81 | 210 | } | |
| @@ -2,13 +2,44 @@ package analyzer | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "go/ast" | 4 | "go/ast" | ||
| 5 | + | "go/parser" | |||
| 6 | + | "go/token" | |||
| 5 | "go/types" | 7 | "go/types" | ||
| 8 | + | "os" | |||
| 9 | + | "path/filepath" | |||
| 10 | + | "sort" | |||
| 6 | "strconv" | 11 | "strconv" | ||
| 7 | "strings" | 12 | "strings" | ||
| 13 | + | "sync" | |||
| 8 | 14 | ||||
| 9 | "github.com/bnema/hexcheck/config" | 15 | "github.com/bnema/hexcheck/config" | ||
| 16 | + | "github.com/bnema/hexcheck/internal/glob" | |||
| 10 | ) | 17 | ) | ||
| 11 | 18 | ||||
| 19 | + | type portInterface struct { | |||
| 20 | + | name string | |||
| 21 | + | iface *types.Interface | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (r runner) checkMissingMocks(file *ast.File, filePath string, current config.Match) { | |||
| 25 | + | if current.Role != config.RolePorts { | |||
| 26 | + | return | |||
| 27 | + | } | |||
| 28 | + | ast.Inspect(file, func(n ast.Node) bool { | |||
| 29 | + | ts, ok := n.(*ast.TypeSpec) | |||
| 30 | + | if !ok { | |||
| 31 | + | return true | |||
| 32 | + | } | |||
| 33 | + | if _, ok := ts.Type.(*ast.InterfaceType); !ok { | |||
| 34 | + | return true | |||
| 35 | + | } | |||
| 36 | + | if !r.generatedMockExists(ts.Name.Name) { | |||
| 37 | + | r.report(ts.Name.Pos(), "missing-generated-mock-for-port", filePath, "port interface %s has no generated mock matching configured paths and name patterns", ts.Name.Name) | |||
| 38 | + | } | |||
| 39 | + | return true | |||
| 40 | + | }) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 12 | func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) { | 43 | func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) { | ||
| 13 | if !strings.HasSuffix(filePath, "_test.go") { | 44 | if !strings.HasSuffix(filePath, "_test.go") { | ||
| 14 | return | 45 | return | ||
| @@ -41,9 +72,12 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||||
| 41 | if !ok || obj.Type() == nil { | 72 | if !ok || obj.Type() == nil { | ||
| 42 | return true | 73 | return true | ||
| 43 | } | 74 | } | ||
| 44 | - | for _, iface := range portInterfaces { | 75 | + | for _, port := range portInterfaces { |
| 45 | - | if types.Implements(obj.Type(), iface) || types.Implements(types.NewPointer(obj.Type()), iface) { | 76 | + | if !r.generatedMockExists(port.name) { |
| 46 | - | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s; prefer generated mocks when available", ts.Name.Name, iface.String()) | 77 | + | continue |
| 78 | + | } | |||
| 79 | + | if types.Implements(obj.Type(), port.iface) || types.Implements(types.NewPointer(obj.Type()), port.iface) { | |||
| 80 | + | r.report(ts.Name.Pos(), "no-local-fakes-for-ports", filePath, "local test double %s implements port interface %s with a generated mock available", ts.Name.Name, port.name) | |||
| 47 | break | 81 | break | ||
| 48 | } | 82 | } | ||
| 49 | } | 83 | } | ||
| @@ -51,8 +85,8 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||||
| 51 | }) | 85 | }) | ||
| 52 | } | 86 | } | ||
| 53 | 87 | ||||
| 54 | - | func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | 88 | + | func (r runner) importedPortInterfaces(file *ast.File) []portInterface { |
| 55 | - | var out []*types.Interface | 89 | + | var out []portInterface |
| 56 | for _, imp := range file.Imports { | 90 | for _, imp := range file.Imports { | ||
| 57 | pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName) | 91 | pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName) | ||
| 58 | if !ok || pkgName.Imported() == nil { | 92 | if !ok || pkgName.Imported() == nil { | ||
| @@ -69,13 +103,108 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |||||
| 69 | } | 103 | } | ||
| 70 | iface, ok := obj.Type().Underlying().(*types.Interface) | 104 | iface, ok := obj.Type().Underlying().(*types.Interface) | ||
| 71 | if ok { | 105 | if ok { | ||
| 72 | - | out = append(out, iface.Complete()) | 106 | + | out = append(out, portInterface{name: name, iface: iface.Complete()}) |
| 73 | } | 107 | } | ||
| 74 | } | 108 | } | ||
| 75 | } | 109 | } | ||
| 76 | return out | 110 | return out | ||
| 77 | } | 111 | } | ||
| 78 | 112 | ||||
| 113 | + | var mockIndexCache sync.Map | |||
| 114 | + | ||||
| 115 | + | func (r runner) generatedMockExists(interfaceName string) bool { | |||
| 116 | + | if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 { | |||
| 117 | + | return false | |||
| 118 | + | } | |||
| 119 | + | index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths) | |||
| 120 | + | for _, name := range r.expectedMockNames(interfaceName) { | |||
| 121 | + | if index[name] { | |||
| 122 | + | return true | |||
| 123 | + | } | |||
| 124 | + | } | |||
| 125 | + | return false | |||
| 126 | + | } | |||
| 127 | + | ||||
| 128 | + | func cachedMockIndex(root string, patterns []string) map[string]bool { | |||
| 129 | + | key := mockIndexKey(root, patterns) | |||
| 130 | + | if cached, ok := mockIndexCache.Load(key); ok { | |||
| 131 | + | return cached.(map[string]bool) | |||
| 132 | + | } | |||
| 133 | + | index := buildMockIndex(root, patterns) | |||
| 134 | + | actual, _ := mockIndexCache.LoadOrStore(key, index) | |||
| 135 | + | return actual.(map[string]bool) | |||
| 136 | + | } | |||
| 137 | + | ||||
| 138 | + | func mockIndexKey(root string, patterns []string) string { | |||
| 139 | + | patterns = append([]string(nil), patterns...) | |||
| 140 | + | sort.Strings(patterns) | |||
| 141 | + | return root + "\x00" + strings.Join(patterns, "\x00") | |||
| 142 | + | } | |||
| 143 | + | ||||
| 144 | + | func buildMockIndex(root string, patterns []string) map[string]bool { | |||
| 145 | + | index := map[string]bool{} | |||
| 146 | + | _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |||
| 147 | + | if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") { | |||
| 148 | + | return nil | |||
| 149 | + | } | |||
| 150 | + | rel, err := filepath.Rel(root, path) | |||
| 151 | + | if err != nil { | |||
| 152 | + | return nil | |||
| 153 | + | } | |||
| 154 | + | rel = filepath.ToSlash(rel) | |||
| 155 | + | if !matchesAnyGlob(patterns, rel) { | |||
| 156 | + | return nil | |||
| 157 | + | } | |||
| 158 | + | for _, name := range declaredTypes(path) { | |||
| 159 | + | index[name] = true | |||
| 160 | + | } | |||
| 161 | + | return nil | |||
| 162 | + | }) | |||
| 163 | + | return index | |||
| 164 | + | } | |||
| 165 | + | ||||
| 166 | + | func declaredTypes(path string) []string { | |||
| 167 | + | file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0) | |||
| 168 | + | if err != nil { | |||
| 169 | + | return nil | |||
| 170 | + | } | |||
| 171 | + | var names []string | |||
| 172 | + | for _, decl := range file.Decls { | |||
| 173 | + | gen, ok := decl.(*ast.GenDecl) | |||
| 174 | + | if !ok || gen.Tok != token.TYPE { | |||
| 175 | + | continue | |||
| 176 | + | } | |||
| 177 | + | for _, spec := range gen.Specs { | |||
| 178 | + | ts, ok := spec.(*ast.TypeSpec) | |||
| 179 | + | if ok { | |||
| 180 | + | names = append(names, ts.Name.Name) | |||
| 181 | + | } | |||
| 182 | + | } | |||
| 183 | + | } | |||
| 184 | + | return names | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | func (r runner) expectedMockNames(interfaceName string) []string { | |||
| 188 | + | patterns := r.cfg.Mocking.GeneratedMockNamePatterns | |||
| 189 | + | if len(patterns) == 0 { | |||
| 190 | + | patterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"} | |||
| 191 | + | } | |||
| 192 | + | out := make([]string, 0, len(patterns)) | |||
| 193 | + | for _, pattern := range patterns { | |||
| 194 | + | out = append(out, strings.ReplaceAll(pattern, "{{Interface}}", interfaceName)) | |||
| 195 | + | } | |||
| 196 | + | return out | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func matchesAnyGlob(patterns []string, path string) bool { | |||
| 200 | + | for _, pattern := range patterns { | |||
| 201 | + | if glob.Match(pattern, path) { | |||
| 202 | + | return true | |||
| 203 | + | } | |||
| 204 | + | } | |||
| 205 | + | return false | |||
| 206 | + | } | |||
| 207 | + | ||||
| 79 | func isTestDoubleName(name string) bool { | 208 | func isTestDoubleName(name string) bool { | ||
| 80 | return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub") | 209 | return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub") | ||
| 81 | } | 210 | } | ||
M analyzer/mocks_test.go +43 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "path/filepath" | |
| 6 | + | "testing" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func TestIsTestDoubleName(t *testing.T) { | |
| 6 | 10 | tests := []struct { | |
| @@ -28,3 +32,41 @@ func TestIsTestDoubleName(t *testing.T) { | |||
| 28 | 32 | }) | |
| 29 | 33 | } | |
| 30 | 34 | } | |
| 35 | + | ||
| 36 | + | func TestDeclaredTypes(t *testing.T) { | |
| 37 | + | tests := []struct { | |
| 38 | + | name string | |
| 39 | + | source string | |
| 40 | + | want []string | |
| 41 | + | }{ | |
| 42 | + | { | |
| 43 | + | name: "plain and generic declarations", | |
| 44 | + | source: `package mocks | |
| 45 | + | ||
| 46 | + | type MockCache[K comparable, V any] struct{} | |
| 47 | + | type ( | |
| 48 | + | MockUserRepository struct{} | |
| 49 | + | helper string | |
| 50 | + | ) | |
| 51 | + | `, | |
| 52 | + | want: []string{"MockCache", "MockUserRepository", "helper"}, | |
| 53 | + | }, | |
| 54 | + | } | |
| 55 | + | for _, tt := range tests { | |
| 56 | + | t.Run(tt.name, func(t *testing.T) { | |
| 57 | + | path := filepath.Join(t.TempDir(), "mocks.go") | |
| 58 | + | if err := os.WriteFile(path, []byte(tt.source), 0o600); err != nil { | |
| 59 | + | t.Fatal(err) | |
| 60 | + | } | |
| 61 | + | got := declaredTypes(path) | |
| 62 | + | if len(got) != len(tt.want) { | |
| 63 | + | t.Fatalf("declaredTypes() = %#v, want %#v", got, tt.want) | |
| 64 | + | } | |
| 65 | + | for i := range got { | |
| 66 | + | if got[i] != tt.want[i] { | |
| 67 | + | t.Fatalf("declaredTypes() = %#v, want %#v", got, tt.want) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | }) | |
| 71 | + | } | |
| 72 | + | } | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package analyzer | 1 | package analyzer | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "os" | |||
| 5 | + | "path/filepath" | |||
| 6 | + | "testing" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | func TestIsTestDoubleName(t *testing.T) { | 9 | func TestIsTestDoubleName(t *testing.T) { | ||
| 6 | tests := []struct { | 10 | tests := []struct { | ||
| @@ -28,3 +32,41 @@ func TestIsTestDoubleName(t *testing.T) { | |||||
| 28 | }) | 32 | }) | ||
| 29 | } | 33 | } | ||
| 30 | } | 34 | } | ||
| 35 | + | ||||
| 36 | + | func TestDeclaredTypes(t *testing.T) { | |||
| 37 | + | tests := []struct { | |||
| 38 | + | name string | |||
| 39 | + | source string | |||
| 40 | + | want []string | |||
| 41 | + | }{ | |||
| 42 | + | { | |||
| 43 | + | name: "plain and generic declarations", | |||
| 44 | + | source: `package mocks | |||
| 45 | + | ||||
| 46 | + | type MockCache[K comparable, V any] struct{} | |||
| 47 | + | type ( | |||
| 48 | + | MockUserRepository struct{} | |||
| 49 | + | helper string | |||
| 50 | + | ) | |||
| 51 | + | `, | |||
| 52 | + | want: []string{"MockCache", "MockUserRepository", "helper"}, | |||
| 53 | + | }, | |||
| 54 | + | } | |||
| 55 | + | for _, tt := range tests { | |||
| 56 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 57 | + | path := filepath.Join(t.TempDir(), "mocks.go") | |||
| 58 | + | if err := os.WriteFile(path, []byte(tt.source), 0o600); err != nil { | |||
| 59 | + | t.Fatal(err) | |||
| 60 | + | } | |||
| 61 | + | got := declaredTypes(path) | |||
| 62 | + | if len(got) != len(tt.want) { | |||
| 63 | + | t.Fatalf("declaredTypes() = %#v, want %#v", got, tt.want) | |||
| 64 | + | } | |||
| 65 | + | for i := range got { | |||
| 66 | + | if got[i] != tt.want[i] { | |||
| 67 | + | t.Fatalf("declaredTypes() = %#v, want %#v", got, tt.want) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | }) | |||
| 71 | + | } | |||
| 72 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/application/port/mocks/mocks.go +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | package mocks | |
| 2 | + | ||
| 3 | + | type MockUserRepository struct{} | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | package mocks | |||
| 2 | + | ||||
| 3 | + | type MockUserRepository struct{} | |||
M analyzer/testdata/src/example.com/project/internal/application/port/port.go +4 -0
| @@ -5,3 +5,7 @@ import "example.com/project/internal/infrastructure/sql" | |||
| 5 | 5 | type UserRepository interface { | |
| 6 | 6 | Find() (*sql.Row, error) // want "no-infra-types-in-ports" | |
| 7 | 7 | } | |
| 8 | + | ||
| 9 | + | type UnmockedRepository interface { // want "missing-generated-mock-for-port" | |
| 10 | + | Save() error | |
| 11 | + | } | |
| @@ -5,3 +5,7 @@ import "example.com/project/internal/infrastructure/sql" | |||||
| 5 | type UserRepository interface { | 5 | type UserRepository interface { | ||
| 6 | Find() (*sql.Row, error) // want "no-infra-types-in-ports" | 6 | Find() (*sql.Row, error) // want "no-infra-types-in-ports" | ||
| 7 | } | 7 | } | ||
| 8 | + | ||||
| 9 | + | type UnmockedRepository interface { // want "missing-generated-mock-for-port" | |||
| 10 | + | Save() error | |||
| 11 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/generated_templ.go +27 -0 generated file: collapsed
M analyzer/testdata/src/example.com/project/internal/infrastructure/http/logic.go +13 -1
| @@ -1,11 +1,23 @@ | |||
| 1 | 1 | package http | |
| 2 | 2 | ||
| 3 | - | func ValidateOrder(total int, active bool) bool { // want "suspicious-business-logic-in-adapter" | |
| 3 | + | func ValidateOrder(total int, active bool, region string) bool { // want "suspicious-business-logic-in-adapter" | |
| 4 | 4 | if total <= 0 { | |
| 5 | 5 | return false | |
| 6 | 6 | } | |
| 7 | 7 | if !active { | |
| 8 | 8 | return false | |
| 9 | 9 | } | |
| 10 | + | if region == "" { | |
| 11 | + | return false | |
| 12 | + | } | |
| 13 | + | if total > 1000 { | |
| 14 | + | return region == "trusted" | |
| 15 | + | } | |
| 16 | + | if region == "blocked" { | |
| 17 | + | return false | |
| 18 | + | } | |
| 19 | + | if total == 42 { | |
| 20 | + | return true | |
| 21 | + | } | |
| 10 | 22 | return true | |
| 11 | 23 | } | |
| @@ -1,11 +1,23 @@ | |||||
| 1 | package http | 1 | package http | ||
| 2 | 2 | ||||
| 3 | - | func ValidateOrder(total int, active bool) bool { // want "suspicious-business-logic-in-adapter" | 3 | + | func ValidateOrder(total int, active bool, region string) bool { // want "suspicious-business-logic-in-adapter" |
| 4 | if total <= 0 { | 4 | if total <= 0 { | ||
| 5 | return false | 5 | return false | ||
| 6 | } | 6 | } | ||
| 7 | if !active { | 7 | if !active { | ||
| 8 | return false | 8 | return false | ||
| 9 | } | 9 | } | ||
| 10 | + | if region == "" { | |||
| 11 | + | return false | |||
| 12 | + | } | |||
| 13 | + | if total > 1000 { | |||
| 14 | + | return region == "trusted" | |||
| 15 | + | } | |||
| 16 | + | if region == "blocked" { | |||
| 17 | + | return false | |||
| 18 | + | } | |||
| 19 | + | if total == 42 { | |||
| 20 | + | return true | |||
| 21 | + | } | |||
| 10 | return true | 22 | return true | ||
| 11 | } | 23 | } | ||
M config/config.go +25 -10
| @@ -31,14 +31,15 @@ const ( | |||
| 31 | 31 | ) | |
| 32 | 32 | ||
| 33 | 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:"-"` | |
| 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 | + | RuleSettings map[string]RuleSetting `yaml:"ruleSettings"` | |
| 41 | + | Allow []Allow `yaml:"allow"` | |
| 42 | + | Root string `yaml:"-"` | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 44 | 45 | type Component struct { | |
| @@ -54,6 +55,7 @@ type ExternalTypes struct { | |||
| 54 | 55 | type Heuristics struct { | |
| 55 | 56 | BusinessLogicThreshold int `yaml:"businessLogicThreshold"` | |
| 56 | 57 | BusinessKeywords []string `yaml:"businessKeywords"` | |
| 58 | + | ExcludeTestFiles *bool `yaml:"excludeTestFiles"` | |
| 57 | 59 | } | |
| 58 | 60 | ||
| 59 | 61 | type Mocking struct { | |
| @@ -61,6 +63,10 @@ type Mocking struct { | |||
| 61 | 63 | GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"` | |
| 62 | 64 | } | |
| 63 | 65 | ||
| 66 | + | type RuleSetting struct { | |
| 67 | + | ExcludePaths []string `yaml:"excludePaths"` | |
| 68 | + | } | |
| 69 | + | ||
| 64 | 70 | type Allow struct { | |
| 65 | 71 | Rule string `yaml:"rule"` | |
| 66 | 72 | Path string `yaml:"path"` | |
| @@ -81,7 +87,11 @@ func Load(path string) (*Config, error) { | |||
| 81 | 87 | if err := yaml.Unmarshal(data, &cfg); err != nil { | |
| 82 | 88 | return nil, err | |
| 83 | 89 | } | |
| 84 | - | cfg.Root = filepath.Dir(path) | |
| 90 | + | root, err := filepath.Abs(filepath.Dir(path)) | |
| 91 | + | if err != nil { | |
| 92 | + | return nil, err | |
| 93 | + | } | |
| 94 | + | cfg.Root = root | |
| 85 | 95 | cfg.applyDefaults() | |
| 86 | 96 | if err := cfg.Validate(); err != nil { | |
| 87 | 97 | return nil, err | |
| @@ -108,7 +118,11 @@ func (c *Config) applyDefaults() { | |||
| 108 | 118 | } | |
| 109 | 119 | } | |
| 110 | 120 | if c.Heuristics.BusinessLogicThreshold == 0 { | |
| 111 | - | c.Heuristics.BusinessLogicThreshold = 4 | |
| 121 | + | c.Heuristics.BusinessLogicThreshold = 8 | |
| 122 | + | } | |
| 123 | + | if c.Heuristics.ExcludeTestFiles == nil { | |
| 124 | + | defaultExcludeTestFiles := true | |
| 125 | + | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | |
| 112 | 126 | } | |
| 113 | 127 | if len(c.Heuristics.BusinessKeywords) == 0 { | |
| 114 | 128 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"} | |
| @@ -127,6 +141,7 @@ func DefaultRuleSeverities() map[string]Severity { | |||
| 127 | 141 | "no-adapter-to-adapter-imports": SeverityWarn, | |
| 128 | 142 | "suspicious-business-logic-in-adapter": SeverityWarn, | |
| 129 | 143 | "no-local-fakes-for-ports": SeverityWarn, | |
| 144 | + | "missing-generated-mock-for-port": SeverityWarn, | |
| 130 | 145 | "prefer-generated-mocks": SeverityWarn, | |
| 131 | 146 | } | |
| 132 | 147 | } | |
| @@ -31,14 +31,15 @@ const ( | |||||
| 31 | ) | 31 | ) | ||
| 32 | 32 | ||||
| 33 | type Config struct { | 33 | type Config struct { | ||
| 34 | - | Version int `yaml:"version"` | 34 | + | Version int `yaml:"version"` |
| 35 | - | Components map[string]Component `yaml:"components"` | 35 | + | Components map[string]Component `yaml:"components"` |
| 36 | - | Rules map[string]Severity `yaml:"rules"` | 36 | + | Rules map[string]Severity `yaml:"rules"` |
| 37 | - | ExternalTypes ExternalTypes `yaml:"externalTypes"` | 37 | + | ExternalTypes ExternalTypes `yaml:"externalTypes"` |
| 38 | - | Heuristics Heuristics `yaml:"heuristics"` | 38 | + | Heuristics Heuristics `yaml:"heuristics"` |
| 39 | - | Mocking Mocking `yaml:"mocking"` | 39 | + | Mocking Mocking `yaml:"mocking"` |
| 40 | - | Allow []Allow `yaml:"allow"` | 40 | + | RuleSettings map[string]RuleSetting `yaml:"ruleSettings"` |
| 41 | - | Root string `yaml:"-"` | 41 | + | Allow []Allow `yaml:"allow"` |
| 42 | + | Root string `yaml:"-"` | |||
| 42 | } | 43 | } | ||
| 43 | 44 | ||||
| 44 | type Component struct { | 45 | type Component struct { | ||
| @@ -54,6 +55,7 @@ type ExternalTypes struct { | |||||
| 54 | type Heuristics struct { | 55 | type Heuristics struct { | ||
| 55 | BusinessLogicThreshold int `yaml:"businessLogicThreshold"` | 56 | BusinessLogicThreshold int `yaml:"businessLogicThreshold"` | ||
| 56 | BusinessKeywords []string `yaml:"businessKeywords"` | 57 | BusinessKeywords []string `yaml:"businessKeywords"` | ||
| 58 | + | ExcludeTestFiles *bool `yaml:"excludeTestFiles"` | |||
| 57 | } | 59 | } | ||
| 58 | 60 | ||||
| 59 | type Mocking struct { | 61 | type Mocking struct { | ||
| @@ -61,6 +63,10 @@ type Mocking struct { | |||||
| 61 | GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"` | 63 | GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"` | ||
| 62 | } | 64 | } | ||
| 63 | 65 | ||||
| 66 | + | type RuleSetting struct { | |||
| 67 | + | ExcludePaths []string `yaml:"excludePaths"` | |||
| 68 | + | } | |||
| 69 | + | ||||
| 64 | type Allow struct { | 70 | type Allow struct { | ||
| 65 | Rule string `yaml:"rule"` | 71 | Rule string `yaml:"rule"` | ||
| 66 | Path string `yaml:"path"` | 72 | Path string `yaml:"path"` | ||
| @@ -81,7 +87,11 @@ func Load(path string) (*Config, error) { | |||||
| 81 | if err := yaml.Unmarshal(data, &cfg); err != nil { | 87 | if err := yaml.Unmarshal(data, &cfg); err != nil { | ||
| 82 | return nil, err | 88 | return nil, err | ||
| 83 | } | 89 | } | ||
| 84 | - | cfg.Root = filepath.Dir(path) | 90 | + | root, err := filepath.Abs(filepath.Dir(path)) |
| 91 | + | if err != nil { | |||
| 92 | + | return nil, err | |||
| 93 | + | } | |||
| 94 | + | cfg.Root = root | |||
| 85 | cfg.applyDefaults() | 95 | cfg.applyDefaults() | ||
| 86 | if err := cfg.Validate(); err != nil { | 96 | if err := cfg.Validate(); err != nil { | ||
| 87 | return nil, err | 97 | return nil, err | ||
| @@ -108,7 +118,11 @@ func (c *Config) applyDefaults() { | |||||
| 108 | } | 118 | } | ||
| 109 | } | 119 | } | ||
| 110 | if c.Heuristics.BusinessLogicThreshold == 0 { | 120 | if c.Heuristics.BusinessLogicThreshold == 0 { | ||
| 111 | - | c.Heuristics.BusinessLogicThreshold = 4 | 121 | + | c.Heuristics.BusinessLogicThreshold = 8 |
| 122 | + | } | |||
| 123 | + | if c.Heuristics.ExcludeTestFiles == nil { | |||
| 124 | + | defaultExcludeTestFiles := true | |||
| 125 | + | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | |||
| 112 | } | 126 | } | ||
| 113 | if len(c.Heuristics.BusinessKeywords) == 0 { | 127 | if len(c.Heuristics.BusinessKeywords) == 0 { | ||
| 114 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"} | 128 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"} | ||
| @@ -127,6 +141,7 @@ func DefaultRuleSeverities() map[string]Severity { | |||||
| 127 | "no-adapter-to-adapter-imports": SeverityWarn, | 141 | "no-adapter-to-adapter-imports": SeverityWarn, | ||
| 128 | "suspicious-business-logic-in-adapter": SeverityWarn, | 142 | "suspicious-business-logic-in-adapter": SeverityWarn, | ||
| 129 | "no-local-fakes-for-ports": SeverityWarn, | 143 | "no-local-fakes-for-ports": SeverityWarn, | ||
| 144 | + | "missing-generated-mock-for-port": SeverityWarn, | |||
| 130 | "prefer-generated-mocks": SeverityWarn, | 145 | "prefer-generated-mocks": SeverityWarn, | ||
| 131 | } | 146 | } | ||
| 132 | } | 147 | } | ||
M config/config_test.go +26 -0
| @@ -82,6 +82,32 @@ components: | |||
| 82 | 82 | } | |
| 83 | 83 | } | |
| 84 | 84 | ||
| 85 | + | func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) { | |
| 86 | + | dir := t.TempDir() | |
| 87 | + | path := filepath.Join(dir, ".hexcheck.yaml") | |
| 88 | + | data := []byte(`version: 1 | |
| 89 | + | components: | |
| 90 | + | adapters: | |
| 91 | + | paths: [internal/infrastructure/**] | |
| 92 | + | role: adapter | |
| 93 | + | heuristics: | |
| 94 | + | excludeTestFiles: false | |
| 95 | + | `) | |
| 96 | + | if err := os.WriteFile(path, data, 0o600); err != nil { | |
| 97 | + | t.Fatal(err) | |
| 98 | + | } | |
| 99 | + | cfg, err := Load(path) | |
| 100 | + | if err != nil { | |
| 101 | + | t.Fatal(err) | |
| 102 | + | } | |
| 103 | + | if cfg.Heuristics.ExcludeTestFiles == nil || *cfg.Heuristics.ExcludeTestFiles { | |
| 104 | + | t.Fatal("excludeTestFiles=false was not preserved") | |
| 105 | + | } | |
| 106 | + | if !filepath.IsAbs(cfg.Root) { | |
| 107 | + | t.Fatalf("Root = %q, want absolute path", cfg.Root) | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 85 | 111 | func TestComponentForPathPrecedence(t *testing.T) { | |
| 86 | 112 | cfg := &Config{Components: map[string]Component{ | |
| 87 | 113 | "all": {Role: RoleCore, Paths: []string{"internal/**"}}, | |
| @@ -82,6 +82,32 @@ components: | |||||
| 82 | } | 82 | } | ||
| 83 | } | 83 | } | ||
| 84 | 84 | ||||
| 85 | + | func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) { | |||
| 86 | + | dir := t.TempDir() | |||
| 87 | + | path := filepath.Join(dir, ".hexcheck.yaml") | |||
| 88 | + | data := []byte(`version: 1 | |||
| 89 | + | components: | |||
| 90 | + | adapters: | |||
| 91 | + | paths: [internal/infrastructure/**] | |||
| 92 | + | role: adapter | |||
| 93 | + | heuristics: | |||
| 94 | + | excludeTestFiles: false | |||
| 95 | + | `) | |||
| 96 | + | if err := os.WriteFile(path, data, 0o600); err != nil { | |||
| 97 | + | t.Fatal(err) | |||
| 98 | + | } | |||
| 99 | + | cfg, err := Load(path) | |||
| 100 | + | if err != nil { | |||
| 101 | + | t.Fatal(err) | |||
| 102 | + | } | |||
| 103 | + | if cfg.Heuristics.ExcludeTestFiles == nil || *cfg.Heuristics.ExcludeTestFiles { | |||
| 104 | + | t.Fatal("excludeTestFiles=false was not preserved") | |||
| 105 | + | } | |||
| 106 | + | if !filepath.IsAbs(cfg.Root) { | |||
| 107 | + | t.Fatalf("Root = %q, want absolute path", cfg.Root) | |||
| 108 | + | } | |||
| 109 | + | } | |||
| 110 | + | ||||
| 85 | func TestComponentForPathPrecedence(t *testing.T) { | 111 | func TestComponentForPathPrecedence(t *testing.T) { | ||
| 86 | cfg := &Config{Components: map[string]Component{ | 112 | cfg := &Config{Components: map[string]Component{ | ||
| 87 | "all": {Role: RoleCore, Paths: []string{"internal/**"}}, | 113 | "all": {Role: RoleCore, Paths: []string{"internal/**"}}, | ||
M examples/hexcheck.yaml +19 -2
| @@ -5,14 +5,20 @@ components: | |||
| 5 | 5 | paths: | |
| 6 | 6 | - internal/domain/** | |
| 7 | 7 | - internal/core/** | |
| 8 | - | usecase: | |
| 8 | + | usecases: | |
| 9 | 9 | role: usecase | |
| 10 | 10 | paths: | |
| 11 | 11 | - internal/application/usecase/** | |
| 12 | + | - internal/application/usecases/** | |
| 13 | + | - internal/usecase/** | |
| 14 | + | - internal/usecases/** | |
| 15 | + | - usecase/** | |
| 16 | + | - usecases/** | |
| 12 | 17 | ports: | |
| 13 | 18 | role: ports | |
| 14 | 19 | paths: | |
| 15 | 20 | - internal/application/port/** | |
| 21 | + | - internal/domain/repository/** | |
| 16 | 22 | adapters: | |
| 17 | 23 | role: adapter | |
| 18 | 24 | paths: | |
| @@ -29,6 +35,8 @@ components: | |||
| 29 | 35 | paths: | |
| 30 | 36 | - '**/mocks/**' | |
| 31 | 37 | - '**/generated/**' | |
| 38 | + | - '**/*_templ.go' | |
| 39 | + | - '**/*_gen.go' | |
| 32 | 40 | ||
| 33 | 41 | rules: | |
| 34 | 42 | no-adapter-imports-in-core: error | |
| @@ -38,8 +46,14 @@ rules: | |||
| 38 | 46 | no-adapter-to-adapter-imports: warn | |
| 39 | 47 | suspicious-business-logic-in-adapter: warn | |
| 40 | 48 | no-local-fakes-for-ports: warn | |
| 49 | + | missing-generated-mock-for-port: warn | |
| 41 | 50 | prefer-generated-mocks: warn | |
| 42 | 51 | ||
| 52 | + | ruleSettings: | |
| 53 | + | suspicious-business-logic-in-adapter: | |
| 54 | + | excludePaths: | |
| 55 | + | - internal/ui/** | |
| 56 | + | ||
| 43 | 57 | externalTypes: | |
| 44 | 58 | frameworkPackages: [] | |
| 45 | 59 | adapterTypePackages: | |
| @@ -47,7 +61,8 @@ externalTypes: | |||
| 47 | 61 | - internal/adapters/** | |
| 48 | 62 | ||
| 49 | 63 | heuristics: | |
| 50 | - | businessLogicThreshold: 4 | |
| 64 | + | businessLogicThreshold: 8 | |
| 65 | + | excludeTestFiles: true | |
| 51 | 66 | businessKeywords: | |
| 52 | 67 | - Validate | |
| 53 | 68 | - Authorize | |
| @@ -61,6 +76,8 @@ mocking: | |||
| 61 | 76 | generatedMockPaths: | |
| 62 | 77 | - internal/mocks/** | |
| 63 | 78 | - internal/application/mocks/** | |
| 79 | + | - internal/application/port/mocks/** | |
| 80 | + | - internal/domain/repository/mocks/** | |
| 64 | 81 | generatedMockNamePatterns: | |
| 65 | 82 | - Mock{{Interface}} | |
| 66 | 83 | - '{{Interface}}Mock' | |
| @@ -5,14 +5,20 @@ components: | |||||
| 5 | paths: | 5 | paths: | ||
| 6 | - internal/domain/** | 6 | - internal/domain/** | ||
| 7 | - internal/core/** | 7 | - internal/core/** | ||
| 8 | - | usecase: | 8 | + | usecases: |
| 9 | role: usecase | 9 | role: usecase | ||
| 10 | paths: | 10 | paths: | ||
| 11 | - internal/application/usecase/** | 11 | - internal/application/usecase/** | ||
| 12 | + | - internal/application/usecases/** | |||
| 13 | + | - internal/usecase/** | |||
| 14 | + | - internal/usecases/** | |||
| 15 | + | - usecase/** | |||
| 16 | + | - usecases/** | |||
| 12 | ports: | 17 | ports: | ||
| 13 | role: ports | 18 | role: ports | ||
| 14 | paths: | 19 | paths: | ||
| 15 | - internal/application/port/** | 20 | - internal/application/port/** | ||
| 21 | + | - internal/domain/repository/** | |||
| 16 | adapters: | 22 | adapters: | ||
| 17 | role: adapter | 23 | role: adapter | ||
| 18 | paths: | 24 | paths: | ||
| @@ -29,6 +35,8 @@ components: | |||||
| 29 | paths: | 35 | paths: | ||
| 30 | - '**/mocks/**' | 36 | - '**/mocks/**' | ||
| 31 | - '**/generated/**' | 37 | - '**/generated/**' | ||
| 38 | + | - '**/*_templ.go' | |||
| 39 | + | - '**/*_gen.go' | |||
| 32 | 40 | ||||
| 33 | rules: | 41 | rules: | ||
| 34 | no-adapter-imports-in-core: error | 42 | no-adapter-imports-in-core: error | ||
| @@ -38,8 +46,14 @@ rules: | |||||
| 38 | no-adapter-to-adapter-imports: warn | 46 | no-adapter-to-adapter-imports: warn | ||
| 39 | suspicious-business-logic-in-adapter: warn | 47 | suspicious-business-logic-in-adapter: warn | ||
| 40 | no-local-fakes-for-ports: warn | 48 | no-local-fakes-for-ports: warn | ||
| 49 | + | missing-generated-mock-for-port: warn | |||
| 41 | prefer-generated-mocks: warn | 50 | prefer-generated-mocks: warn | ||
| 42 | 51 | ||||
| 52 | + | ruleSettings: | |||
| 53 | + | suspicious-business-logic-in-adapter: | |||
| 54 | + | excludePaths: | |||
| 55 | + | - internal/ui/** | |||
| 56 | + | ||||
| 43 | externalTypes: | 57 | externalTypes: | ||
| 44 | frameworkPackages: [] | 58 | frameworkPackages: [] | ||
| 45 | adapterTypePackages: | 59 | adapterTypePackages: | ||
| @@ -47,7 +61,8 @@ externalTypes: | |||||
| 47 | - internal/adapters/** | 61 | - internal/adapters/** | ||
| 48 | 62 | ||||
| 49 | heuristics: | 63 | heuristics: | ||
| 50 | - | businessLogicThreshold: 4 | 64 | + | businessLogicThreshold: 8 |
| 65 | + | excludeTestFiles: true | |||
| 51 | businessKeywords: | 66 | businessKeywords: | ||
| 52 | - Validate | 67 | - Validate | ||
| 53 | - Authorize | 68 | - Authorize | ||
| @@ -61,6 +76,8 @@ mocking: | |||||
| 61 | generatedMockPaths: | 76 | generatedMockPaths: | ||
| 62 | - internal/mocks/** | 77 | - internal/mocks/** | ||
| 63 | - internal/application/mocks/** | 78 | - internal/application/mocks/** | ||
| 79 | + | - internal/application/port/mocks/** | |||
| 80 | + | - internal/domain/repository/mocks/** | |||
| 64 | generatedMockNamePatterns: | 81 | generatedMockNamePatterns: | ||
| 65 | - Mock{{Interface}} | 82 | - Mock{{Interface}} | ||
| 66 | - '{{Interface}}Mock' | 83 | - '{{Interface}}Mock' | ||
M test/smoke/smoke_test.go +6 -2
| @@ -31,9 +31,13 @@ func TestLocalRepositorySmoke(t *testing.T) { | |||
| 31 | 31 | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | |
| 32 | 32 | cmd.Dir = tt.repo | |
| 33 | 33 | output, err := cmd.CombinedOutput() | |
| 34 | - | if err != nil && len(output) == 0 { | |
| 35 | - | t.Fatalf("hexcheck smoke command failed without output: %v", err) | |
| 34 | + | if err == nil { | |
| 35 | + | return | |
| 36 | 36 | } | |
| 37 | + | if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 3 && len(output) > 0 { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | t.Fatalf("hexcheck smoke command failed: %v\n%s", err, output) | |
| 37 | 41 | }) | |
| 38 | 42 | } | |
| 39 | 43 | } | |
| @@ -31,9 +31,13 @@ func TestLocalRepositorySmoke(t *testing.T) { | |||||
| 31 | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | 31 | cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...") | ||
| 32 | cmd.Dir = tt.repo | 32 | cmd.Dir = tt.repo | ||
| 33 | output, err := cmd.CombinedOutput() | 33 | output, err := cmd.CombinedOutput() | ||
| 34 | - | if err != nil && len(output) == 0 { | 34 | + | if err == nil { |
| 35 | - | t.Fatalf("hexcheck smoke command failed without output: %v", err) | 35 | + | return |
| 36 | } | 36 | } | ||
| 37 | + | if exit, ok := err.(*exec.ExitError); ok && exit.ExitCode() == 3 && len(output) > 0 { | |||
| 38 | + | return | |||
| 39 | + | } | |||
| 40 | + | t.Fatalf("hexcheck smoke command failed: %v\n%s", err, output) | |||
| 37 | }) | 41 | }) | ||
| 38 | } | 42 | } | ||
| 39 | } | 43 | } | ||