fix(analyzer): cover heuristic and mock rules
- commit
- cd0745ff76717d78f7185012396eb0fef141cbce
- author
- brice · · 2026-05-30 05:10 UTC
- parent
- 1814f2b
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +61 -10
M analyzer/analyzer.go +2 -6
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/hexcheck/config" | |
| 13 | + | "github.com/bnema/hexcheck/internal/glob" | |
| 13 | 14 | "golang.org/x/tools/go/analysis" | |
| 14 | 15 | ) | |
| 15 | 16 | ||
| @@ -228,7 +229,7 @@ func (r runner) isInfraType(t types.Type) bool { | |||
| 228 | 229 | ||
| 229 | 230 | func (r runner) matchesExternal(pkg string) bool { | |
| 230 | 231 | for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) { | |
| 231 | - | if simpleMatch(pattern, pkg) { | |
| 232 | + | if glob.Match(pattern, pkg) || glob.Match(pattern+"/**", pkg) { | |
| 232 | 233 | return true | |
| 233 | 234 | } | |
| 234 | 235 | } | |
| @@ -266,8 +267,3 @@ func (r runner) filePath(file *ast.File) string { | |||
| 266 | 267 | } | |
| 267 | 268 | return pos | |
| 268 | 269 | } | |
| 269 | - | ||
| 270 | - | func simpleMatch(pattern, name string) bool { | |
| 271 | - | pattern = strings.TrimSuffix(pattern, "/**") | |
| 272 | - | return name == pattern || strings.HasPrefix(name, pattern+"/") | |
| 273 | - | } | |
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | "github.com/bnema/hexcheck/config" | 12 | "github.com/bnema/hexcheck/config" | ||
| 13 | + | "github.com/bnema/hexcheck/internal/glob" | |||
| 13 | "golang.org/x/tools/go/analysis" | 14 | "golang.org/x/tools/go/analysis" | ||
| 14 | ) | 15 | ) | ||
| 15 | 16 | ||||
| @@ -228,7 +229,7 @@ func (r runner) isInfraType(t types.Type) bool { | |||||
| 228 | 229 | ||||
| 229 | func (r runner) matchesExternal(pkg string) bool { | 230 | func (r runner) matchesExternal(pkg string) bool { | ||
| 230 | for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) { | 231 | for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) { | ||
| 231 | - | if simpleMatch(pattern, pkg) { | 232 | + | if glob.Match(pattern, pkg) || glob.Match(pattern+"/**", pkg) { |
| 232 | return true | 233 | return true | ||
| 233 | } | 234 | } | ||
| 234 | } | 235 | } | ||
| @@ -266,8 +267,3 @@ func (r runner) filePath(file *ast.File) string { | |||||
| 266 | } | 267 | } | ||
| 267 | return pos | 268 | return pos | ||
| 268 | } | 269 | } | ||
| 269 | - | ||||
| 270 | - | func simpleMatch(pattern, name string) bool { | |||
| 271 | - | pattern = strings.TrimSuffix(pattern, "/**") | |||
| 272 | - | return name == pattern || strings.HasPrefix(name, pattern+"/") | |||
| 273 | - | } | |||
M analyzer/analyzer_test.go +1 -1
| @@ -13,7 +13,7 @@ func TestAnalyzer(t *testing.T) { | |||
| 13 | 13 | patterns []string | |
| 14 | 14 | }{ | |
| 15 | 15 | { | |
| 16 | - | name: "boundary imports", | |
| 16 | + | name: "boundary imports and heuristics", | |
| 17 | 17 | patterns: []string{ | |
| 18 | 18 | "example.com/project/internal/domain", | |
| 19 | 19 | "example.com/project/internal/application/usecase", | |
| @@ -13,7 +13,7 @@ func TestAnalyzer(t *testing.T) { | |||||
| 13 | patterns []string | 13 | patterns []string | ||
| 14 | }{ | 14 | }{ | ||
| 15 | { | 15 | { | ||
| 16 | - | name: "boundary imports", | 16 | + | name: "boundary imports and heuristics", |
| 17 | patterns: []string{ | 17 | patterns: []string{ | ||
| 18 | "example.com/project/internal/domain", | 18 | "example.com/project/internal/domain", | ||
| 19 | "example.com/project/internal/application/usecase", | 19 | "example.com/project/internal/application/usecase", | ||
M analyzer/mocks.go +28 -3
| @@ -34,8 +34,7 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||
| 34 | 34 | return true | |
| 35 | 35 | } | |
| 36 | 36 | ||
| 37 | - | name := strings.ToLower(ts.Name.Name) | |
| 38 | - | if !isTestDoubleName(name) { | |
| 37 | + | if !isTestDoubleName(ts.Name.Name) { | |
| 39 | 38 | return true | |
| 40 | 39 | } | |
| 41 | 40 | obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName) | |
| @@ -78,5 +77,31 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |||
| 78 | 77 | } | |
| 79 | 78 | ||
| 80 | 79 | func isTestDoubleName(name string) bool { | |
| 81 | - | return strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") | |
| 80 | + | return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub") | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | func hasWordPrefix(name, prefix string) bool { | |
| 84 | + | if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) { | |
| 85 | + | return true | |
| 86 | + | } | |
| 87 | + | if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) { | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | return startsCamelWord(name[len(prefix):]) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func hasWordSuffix(name, suffix string) bool { | |
| 94 | + | if len(name) < len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) { | |
| 95 | + | return false | |
| 96 | + | } | |
| 97 | + | prefix := name[:len(name)-len(suffix)] | |
| 98 | + | return prefix == "" || strings.HasSuffix(prefix, "_") | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | func startsCamelWord(s string) bool { | |
| 102 | + | if s == "" { | |
| 103 | + | return true | |
| 104 | + | } | |
| 105 | + | r := rune(s[0]) | |
| 106 | + | return r == '_' || (r >= 'A' && r <= 'Z') | |
| 82 | 107 | } | |
| @@ -34,8 +34,7 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match | |||||
| 34 | return true | 34 | return true | ||
| 35 | } | 35 | } | ||
| 36 | 36 | ||||
| 37 | - | name := strings.ToLower(ts.Name.Name) | 37 | + | if !isTestDoubleName(ts.Name.Name) { |
| 38 | - | if !isTestDoubleName(name) { | |||
| 39 | return true | 38 | return true | ||
| 40 | } | 39 | } | ||
| 41 | obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName) | 40 | obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName) | ||
| @@ -78,5 +77,31 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface { | |||||
| 78 | } | 77 | } | ||
| 79 | 78 | ||||
| 80 | func isTestDoubleName(name string) bool { | 79 | func isTestDoubleName(name string) bool { | ||
| 81 | - | return strings.HasPrefix(name, "fake") || strings.HasSuffix(name, "fake") || strings.HasPrefix(name, "stub") || strings.HasSuffix(name, "stub") | 80 | + | return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub") |
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func hasWordPrefix(name, prefix string) bool { | |||
| 84 | + | if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) { | |||
| 85 | + | return true | |||
| 86 | + | } | |||
| 87 | + | if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) { | |||
| 88 | + | return false | |||
| 89 | + | } | |||
| 90 | + | return startsCamelWord(name[len(prefix):]) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func hasWordSuffix(name, suffix string) bool { | |||
| 94 | + | if len(name) < len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) { | |||
| 95 | + | return false | |||
| 96 | + | } | |||
| 97 | + | prefix := name[:len(name)-len(suffix)] | |||
| 98 | + | return prefix == "" || strings.HasSuffix(prefix, "_") | |||
| 99 | + | } | |||
| 100 | + | ||||
| 101 | + | func startsCamelWord(s string) bool { | |||
| 102 | + | if s == "" { | |||
| 103 | + | return true | |||
| 104 | + | } | |||
| 105 | + | r := rune(s[0]) | |||
| 106 | + | return r == '_' || (r >= 'A' && r <= 'Z') | |||
| 82 | } | 107 | } | ||
A analyzer/mocks_test.go +30 -0
| @@ -0,0 +1,30 @@ | |||
| 1 | + | package analyzer | |
| 2 | + | ||
| 3 | + | import "testing" | |
| 4 | + | ||
| 5 | + | func TestIsTestDoubleName(t *testing.T) { | |
| 6 | + | tests := []struct { | |
| 7 | + | name string | |
| 8 | + | want bool | |
| 9 | + | }{ | |
| 10 | + | {name: "fakeUserRepository", want: true}, | |
| 11 | + | {name: "FakeUserRepository", want: true}, | |
| 12 | + | {name: "stubClock", want: true}, | |
| 13 | + | {name: "StubClock", want: true}, | |
| 14 | + | {name: "user_fake", want: true}, | |
| 15 | + | {name: "fake", want: true}, | |
| 16 | + | {name: "stub", want: true}, | |
| 17 | + | {name: "StubbornHandler", want: false}, | |
| 18 | + | {name: "stubbornHandler", want: false}, | |
| 19 | + | {name: "FakeoutService", want: false}, | |
| 20 | + | {name: "fakeoutService", want: false}, | |
| 21 | + | {name: "outfake", want: false}, | |
| 22 | + | } | |
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | if got := isTestDoubleName(tt.name); got != tt.want { | |
| 26 | + | t.Fatalf("isTestDoubleName(%q) = %v, want %v", tt.name, got, tt.want) | |
| 27 | + | } | |
| 28 | + | }) | |
| 29 | + | } | |
| 30 | + | } | |
| @@ -0,0 +1,30 @@ | |||||
| 1 | + | package analyzer | |||
| 2 | + | ||||
| 3 | + | import "testing" | |||
| 4 | + | ||||
| 5 | + | func TestIsTestDoubleName(t *testing.T) { | |||
| 6 | + | tests := []struct { | |||
| 7 | + | name string | |||
| 8 | + | want bool | |||
| 9 | + | }{ | |||
| 10 | + | {name: "fakeUserRepository", want: true}, | |||
| 11 | + | {name: "FakeUserRepository", want: true}, | |||
| 12 | + | {name: "stubClock", want: true}, | |||
| 13 | + | {name: "StubClock", want: true}, | |||
| 14 | + | {name: "user_fake", want: true}, | |||
| 15 | + | {name: "fake", want: true}, | |||
| 16 | + | {name: "stub", want: true}, | |||
| 17 | + | {name: "StubbornHandler", want: false}, | |||
| 18 | + | {name: "stubbornHandler", want: false}, | |||
| 19 | + | {name: "FakeoutService", want: false}, | |||
| 20 | + | {name: "fakeoutService", want: false}, | |||
| 21 | + | {name: "outfake", want: false}, | |||
| 22 | + | } | |||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | if got := isTestDoubleName(tt.name); got != tt.want { | |||
| 26 | + | t.Fatalf("isTestDoubleName(%q) = %v, want %v", tt.name, got, tt.want) | |||
| 27 | + | } | |||
| 28 | + | }) | |||
| 29 | + | } | |||
| 30 | + | } | |||