1package analyzer
2
3import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "go/types"
8 "os"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 "sync"
14
15 "github.com/bnema/hexcheck/config"
16 "github.com/bnema/hexcheck/internal/glob"
17)
18
19type portInterface struct {
20 name string
21 iface *types.Interface
22}
23
24func (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
43func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
44 if !strings.HasSuffix(filePath, "_test.go") {
45 return
46 }
47
48 if current.Role == config.RoleUsecase || current.Role == config.RoleCore {
49 for _, imp := range file.Imports {
50 importPath, _ := strconv.Unquote(imp.Path.Value)
51 if match, ok := r.cfg.ComponentForPath(r.relImportPath(importPath)); ok && match.Role == config.RoleAdapter {
52 r.report(imp.Pos(), "prefer-generated-mocks", filePath, "test imports concrete adapter %s; prefer generated mocks for port dependencies", importPath)
53 }
54 }
55 }
56
57 portInterfaces := r.importedPortInterfaces(file)
58 if len(portInterfaces) == 0 {
59 return
60 }
61
62 ast.Inspect(file, func(n ast.Node) bool {
63 ts, ok := n.(*ast.TypeSpec)
64 if !ok {
65 return true
66 }
67
68 if !isTestDoubleName(ts.Name.Name) {
69 return true
70 }
71 obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName)
72 if !ok || obj.Type() == nil {
73 return true
74 }
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)
81 break
82 }
83 }
84 return true
85 })
86}
87
88func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
89 var out []portInterface
90 for _, imp := range file.Imports {
91 pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName)
92 if !ok || pkgName.Imported() == nil {
93 continue
94 }
95 if match, ok := r.cfg.ComponentForPath(r.relImportPath(pkgName.Imported().Path())); !ok || match.Role != config.RolePorts {
96 continue
97 }
98 scope := pkgName.Imported().Scope()
99 for _, name := range scope.Names() {
100 obj, ok := scope.Lookup(name).(*types.TypeName)
101 if !ok {
102 continue
103 }
104 iface, ok := obj.Type().Underlying().(*types.Interface)
105 if ok {
106 out = append(out, portInterface{name: name, iface: iface.Complete()})
107 }
108 }
109 }
110 return out
111}
112
113var mockIndexCache sync.Map
114
115func (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
128func 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
138func 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
144func 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
166func 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
187func (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
199func 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
208func isTestDoubleName(name string) bool {
209 return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub")
210}
211
212func hasWordPrefix(name, prefix string) bool {
213 if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214 return true
215 }
216 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217 return false
218 }
219 return startsCamelWord(name[len(prefix):])
220}
221
222func hasWordSuffix(name, suffix string) bool {
223 if len(name) < len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) {
224 return false
225 }
226 prefix := name[:len(name)-len(suffix)]
227 return prefix == "" || strings.HasSuffix(prefix, "_")
228}
229
230func startsCamelWord(s string) bool {
231 if s == "" {
232 return true
233 }
234 r := rune(s[0])
235 return r == '_' || (r >= 'A' && r <= 'Z')
236}