detached 4de0705

fix(analyzer): tune heuristics and mock detection

commit
4de0705ecb463cf07158255873d67a5c9720f963
author
brice · · 2026-05-30 05:49 UTC
parent
cd0745f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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 {
5555
5656 func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
5757 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
5967 }
6068 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+ }
6176 configPath := firstNonEmpty(opts.ConfigPath, configPathFlag)
6277 if configPath == "" {
6378 start := root
@@ -129,8 +144,12 @@ func (r runner) run() {
129144 }
130145 for _, file := range r.pass.Files {
131146 filePath := r.filePath(file)
147+ if isGeneratedFile(file) {
148+ continue
149+ }
132150 r.checkImports(file, filePath, current)
133151 r.checkTypeLeaks(file, filePath, current)
152+ r.checkMissingMocks(file, filePath, current)
134153 r.checkBusinessLogic(file, filePath, current)
135154 r.checkMocks(file, filePath, current)
136155 }
@@ -237,12 +256,25 @@ func (r runner) matchesExternal(pkg string) bool {
237256 }
238257
239258 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) {
241260 return
242261 }
243262 r.pass.Reportf(pos, rule+": "+format, args...)
244263 }
245264
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+
246278 func (r runner) relImportPath(importPath string) string {
247279 if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {
248280 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, nil58+ 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 := root78 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 return260 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 @@
11 package analyzer
22
33 import (
4+ "path/filepath"
45 "testing"
56
67 "github.com/bnema/hexcheck/config"
@@ -37,6 +38,7 @@ func TestAnalyzer(t *testing.T) {
3738
3839 func testConfig() *config.Config {
3940 cfg := &config.Config{
41+ Root: filepath.Join(analysistest.TestData(), "src/example.com/project"),
4042 Version: 1,
4143 Components: map[string]config.Component{
4244 "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}},
@@ -49,6 +51,9 @@ func testConfig() *config.Config {
4951 ExternalTypes: config.ExternalTypes{
5052 FrameworkPackages: []string{"example.com/framework"},
5153 },
54+ Mocking: config.Mocking{
55+ GeneratedMockPaths: []string{"internal/application/port/mocks/**"},
56+ },
5257 Allow: []config.Allow{
5358 {Rule: "no-infra-imports-in-usecase", Path: "internal/application/usecase/*_test.go", Reason: "test architecture rule has a more specific diagnostic"},
5459 },
@@ -1,6 +1,7 @@
1 package analyzer1 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
1111 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
1212 return
1313 }
14+ if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") {
15+ return
16+ }
1417
1518 threshold := r.cfg.Heuristics.BusinessLogicThreshold
1619 for _, decl := range file.Decls {
@@ -21,7 +24,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
2124
2225 score := 0
2326 for _, keyword := range r.cfg.Heuristics.BusinessKeywords {
24- if strings.Contains(fn.Name.Name, keyword) {
27+ if containsWord(fn.Name.Name, keyword) {
2528 score += 2
2629 }
2730 }
@@ -39,3 +42,25 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
3942 }
4043 }
4144 }
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 return12 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.BusinessLogicThreshold18 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 := 025 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 += 228 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
22
33 import (
44 "go/ast"
5+ "go/parser"
6+ "go/token"
57 "go/types"
8+ "os"
9+ "path/filepath"
10+ "sort"
611 "strconv"
712 "strings"
13+ "sync"
814
915 "github.com/bnema/hexcheck/config"
16+ "github.com/bnema/hexcheck/internal/glob"
1017 )
1118
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+
1243 func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
1344 if !strings.HasSuffix(filePath, "_test.go") {
1445 return
@@ -41,9 +72,12 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match
4172 if !ok || obj.Type() == nil {
4273 return true
4374 }
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)
4781 break
4882 }
4983 }
@@ -51,8 +85,8 @@ func (r runner) checkMocks(file *ast.File, filePath string, current config.Match
5185 })
5286 }
5387
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
5690 for _, imp := range file.Imports {
5791 pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName)
5892 if !ok || pkgName.Imported() == nil {
@@ -69,13 +103,108 @@ func (r runner) importedPortInterfaces(file *ast.File) []*types.Interface {
69103 }
70104 iface, ok := obj.Type().Underlying().(*types.Interface)
71105 if ok {
72- out = append(out, iface.Complete())
106+ out = append(out, portInterface{name: name, iface: iface.Complete()})
73107 }
74108 }
75109 }
76110 return out
77111 }
78112
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+
79208 func isTestDoubleName(name string) bool {
80209 return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub")
81210 }
@@ -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 return45 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 true73 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 break81 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.Interface89+ 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 out110 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 @@
11 package analyzer
22
3-import "testing"
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+)
48
59 func TestIsTestDoubleName(t *testing.T) {
610 tests := []struct {
@@ -28,3 +32,41 @@ func TestIsTestDoubleName(t *testing.T) {
2832 })
2933 }
3034 }
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 analyzer1 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"
55 type UserRepository interface {
66 Find() (*sql.Row, error) // want "no-infra-types-in-ports"
77 }
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 @@
11 package http
22
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"
44 if total <= 0 {
55 return false
66 }
77 if !active {
88 return false
99 }
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+ }
1022 return true
1123 }
@@ -1,11 +1,23 @@
1 package http1 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 false5 return false
6 }6 }
7 if !active {7 if !active {
8 return false8 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 true22 return true
11 }23 }
M config/config.go +25 -10
@@ -31,14 +31,15 @@ const (
3131 )
3232
3333 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:"-"`
4243 }
4344
4445 type Component struct {
@@ -54,6 +55,7 @@ type ExternalTypes struct {
5455 type Heuristics struct {
5556 BusinessLogicThreshold int `yaml:"businessLogicThreshold"`
5657 BusinessKeywords []string `yaml:"businessKeywords"`
58+ ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
5759 }
5860
5961 type Mocking struct {
@@ -61,6 +63,10 @@ type Mocking struct {
6163 GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"`
6264 }
6365
66+type RuleSetting struct {
67+ ExcludePaths []string `yaml:"excludePaths"`
68+}
69+
6470 type Allow struct {
6571 Rule string `yaml:"rule"`
6672 Path string `yaml:"path"`
@@ -81,7 +87,11 @@ func Load(path string) (*Config, error) {
8187 if err := yaml.Unmarshal(data, &cfg); err != nil {
8288 return nil, err
8389 }
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
8595 cfg.applyDefaults()
8696 if err := cfg.Validate(); err != nil {
8797 return nil, err
@@ -108,7 +118,11 @@ func (c *Config) applyDefaults() {
108118 }
109119 }
110120 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
112126 }
113127 if len(c.Heuristics.BusinessKeywords) == 0 {
114128 c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"}
@@ -127,6 +141,7 @@ func DefaultRuleSeverities() map[string]Severity {
127141 "no-adapter-to-adapter-imports": SeverityWarn,
128142 "suspicious-business-logic-in-adapter": SeverityWarn,
129143 "no-local-fakes-for-ports": SeverityWarn,
144+ "missing-generated-mock-for-port": SeverityWarn,
130145 "prefer-generated-mocks": SeverityWarn,
131146 }
132147 }
@@ -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, err88 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, err97 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 = 4121+ 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:
8282 }
8383 }
8484
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+
85111 func TestComponentForPathPrecedence(t *testing.T) {
86112 cfg := &Config{Components: map[string]Component{
87113 "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:
55 paths:
66 - internal/domain/**
77 - internal/core/**
8- usecase:
8+ usecases:
99 role: usecase
1010 paths:
1111 - internal/application/usecase/**
12+ - internal/application/usecases/**
13+ - internal/usecase/**
14+ - internal/usecases/**
15+ - usecase/**
16+ - usecases/**
1217 ports:
1318 role: ports
1419 paths:
1520 - internal/application/port/**
21+ - internal/domain/repository/**
1622 adapters:
1723 role: adapter
1824 paths:
@@ -29,6 +35,8 @@ components:
2935 paths:
3036 - '**/mocks/**'
3137 - '**/generated/**'
38+ - '**/*_templ.go'
39+ - '**/*_gen.go'
3240
3341 rules:
3442 no-adapter-imports-in-core: error
@@ -38,8 +46,14 @@ rules:
3846 no-adapter-to-adapter-imports: warn
3947 suspicious-business-logic-in-adapter: warn
4048 no-local-fakes-for-ports: warn
49+ missing-generated-mock-for-port: warn
4150 prefer-generated-mocks: warn
4251
52+ruleSettings:
53+ suspicious-business-logic-in-adapter:
54+ excludePaths:
55+ - internal/ui/**
56+
4357 externalTypes:
4458 frameworkPackages: []
4559 adapterTypePackages:
@@ -47,7 +61,8 @@ externalTypes:
4761 - internal/adapters/**
4862
4963 heuristics:
50- businessLogicThreshold: 4
64+ businessLogicThreshold: 8
65+ excludeTestFiles: true
5166 businessKeywords:
5267 - Validate
5368 - Authorize
@@ -61,6 +76,8 @@ mocking:
6176 generatedMockPaths:
6277 - internal/mocks/**
6378 - internal/application/mocks/**
79+ - internal/application/port/mocks/**
80+ - internal/domain/repository/mocks/**
6481 generatedMockNamePatterns:
6582 - Mock{{Interface}}
6683 - '{{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: usecase9 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: ports18 role: ports
14 paths:19 paths:
15 - internal/application/port/**20 - internal/application/port/**
21+ - internal/domain/repository/**
16 adapters:22 adapters:
17 role: adapter23 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: error42 no-adapter-imports-in-core: error
@@ -38,8 +46,14 @@ rules:
38 no-adapter-to-adapter-imports: warn46 no-adapter-to-adapter-imports: warn
39 suspicious-business-logic-in-adapter: warn47 suspicious-business-logic-in-adapter: warn
40 no-local-fakes-for-ports: warn48 no-local-fakes-for-ports: warn
49+ missing-generated-mock-for-port: warn
41 prefer-generated-mocks: warn50 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: 464+ businessLogicThreshold: 8
65+ excludeTestFiles: true
51 businessKeywords:66 businessKeywords:
52 - Validate67 - Validate
53 - Authorize68 - 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) {
3131 cmd := exec.Command(bin, "-hexcheck.config", filepath.Join(root, "examples", "hexcheck.yaml"), "-hexcheck.root", tt.repo, "./...")
3232 cmd.Dir = tt.repo
3333 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
3636 }
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)
3741 })
3842 }
3943 }
@@ -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.repo32 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 }

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