detached cd0745f

fix(analyzer): cover heuristic and mock rules

commit
cd0745ff76717d78f7185012396eb0fef141cbce
author
brice · · 2026-05-30 05:10 UTC
parent
1814f2b
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +61 -10
M analyzer/analyzer.go +2 -6
@@ -10,6 +10,7 @@ import (
1010 "strings"
1111
1212 "github.com/bnema/hexcheck/config"
13+ "github.com/bnema/hexcheck/internal/glob"
1314 "golang.org/x/tools/go/analysis"
1415 )
1516
@@ -228,7 +229,7 @@ func (r runner) isInfraType(t types.Type) bool {
228229
229230 func (r runner) matchesExternal(pkg string) bool {
230231 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) {
232233 return true
233234 }
234235 }
@@ -266,8 +267,3 @@ func (r runner) filePath(file *ast.File) string {
266267 }
267268 return pos
268269 }
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 true233 return true
233 }234 }
234 }235 }
@@ -266,8 +267,3 @@ func (r runner) filePath(file *ast.File) string {
266 }267 }
267 return pos268 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) {
1313 patterns []string
1414 }{
1515 {
16- name: "boundary imports",
16+ name: "boundary imports and heuristics",
1717 patterns: []string{
1818 "example.com/project/internal/domain",
1919 "example.com/project/internal/application/usecase",
@@ -13,7 +13,7 @@ func TestAnalyzer(t *testing.T) {
13 patterns []string13 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
3434 return true
3535 }
3636
37- name := strings.ToLower(ts.Name.Name)
38- if !isTestDoubleName(name) {
37+ if !isTestDoubleName(ts.Name.Name) {
3938 return true
4039 }
4140 obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName)
@@ -78,5 +77,31 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface {
7877 }
7978
8079 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')
82107 }
@@ -34,8 +34,7 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match
34 return true34 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 true38 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+}

Keyboard shortcuts

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

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

Branches 1

Tags 5

↑↓ moveenter openesc close