1package analyzer
2
3import (
4 "go/ast"
5 "go/parser"
6 "go/token"
7 "go/types"
8 "os"
9 "path/filepath"
10 "strconv"
11 "strings"
12
13 "github.com/bnema/hexcheck/config"
14 "github.com/bnema/hexcheck/internal/glob"
15)
16
17type portInterface struct {
18 name string
19 iface *types.Interface
20}
21
22func (r runner) checkMissingMocks(file *ast.File, filePath string, current config.Match) {
23 if current.Role != config.RolePorts {
24 return
25 }
26 ast.Inspect(file, func(n ast.Node) bool {
27 ts, ok := n.(*ast.TypeSpec)
28 if !ok {
29 return true
30 }
31 if _, ok := ts.Type.(*ast.InterfaceType); !ok {
32 return true
33 }
34 if !r.generatedMockExists(ts.Name.Name) {
35 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)
36 }
37 return true
38 })
39}
40
41func (r runner) checkMocks(file *ast.File, filePath string, current config.Match) {
42 if !strings.HasSuffix(filePath, "_test.go") {
43 return
44 }
45
46 if current.Role == config.RoleUsecase || current.Role == config.RoleCore {
47 for _, imp := range file.Imports {
48 importPath, _ := strconv.Unquote(imp.Path.Value)
49 if match, ok := r.cfg.ComponentForPath(r.relImportPath(importPath)); ok && match.Role == config.RoleAdapter {
50 r.report(imp.Pos(), "prefer-generated-mocks", filePath, "test imports concrete adapter %s; prefer generated mocks for port dependencies", importPath)
51 }
52 }
53 }
54
55 portInterfaces := r.importedPortInterfaces(file)
56 if len(portInterfaces) == 0 {
57 return
58 }
59
60 ast.Inspect(file, func(n ast.Node) bool {
61 ts, ok := n.(*ast.TypeSpec)
62 if !ok {
63 return true
64 }
65
66 if !isTestDoubleName(ts.Name.Name) {
67 return true
68 }
69 obj, ok := r.pass.TypesInfo.Defs[ts.Name].(*types.TypeName)
70 if !ok || obj.Type() == nil {
71 return true
72 }
73 for _, port := range portInterfaces {
74 if !r.generatedMockExists(port.name) {
75 continue
76 }
77 if types.Implements(obj.Type(), port.iface) || types.Implements(types.NewPointer(obj.Type()), port.iface) {
78 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)
79 break
80 }
81 }
82 return true
83 })
84}
85
86func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
87 var out []portInterface
88 for _, imp := range file.Imports {
89 pkgName, ok := r.pass.TypesInfo.Implicits[imp].(*types.PkgName)
90 if !ok || pkgName.Imported() == nil {
91 continue
92 }
93 if match, ok := r.cfg.ComponentForPath(r.relImportPath(pkgName.Imported().Path())); !ok || match.Role != config.RolePorts {
94 continue
95 }
96 scope := pkgName.Imported().Scope()
97 for _, name := range scope.Names() {
98 obj, ok := scope.Lookup(name).(*types.TypeName)
99 if !ok {
100 continue
101 }
102 iface, ok := obj.Type().Underlying().(*types.Interface)
103 if ok {
104 out = append(out, portInterface{name: name, iface: iface.Complete()})
105 }
106 }
107 }
108 return out
109}
110
111func (r runner) generatedMockExists(interfaceName string) bool {
112 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
113 return false
114 }
115 index := r.mockIndex()
116 for _, name := range r.expectedMockNames(interfaceName) {
117 if index[name] {
118 return true
119 }
120 }
121 return false
122}
123
124// mockIndex returns the generated mock type names, built once per analyzer
125// run. The index lives in the analyzer state, not in a process-wide cache, so
126// each new run sees freshly generated mocks.
127func (r runner) mockIndex() map[string]bool {
128 if r.state == nil {
129 return buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
130 }
131 r.state.mockOnce.Do(func() {
132 r.state.mocks = buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
133 })
134 return r.state.mocks
135}
136
137func buildMockIndex(root string, patterns []string) map[string]bool {
138 index := map[string]bool{}
139 _ = filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error {
140 if err != nil || d.IsDir() || !strings.HasSuffix(path, ".go") {
141 return nil
142 }
143 rel, err := filepath.Rel(root, path)
144 if err != nil {
145 return nil
146 }
147 rel = filepath.ToSlash(rel)
148 if !matchesAnyGlob(patterns, rel) {
149 return nil
150 }
151 for _, name := range declaredTypes(path) {
152 index[name] = true
153 }
154 return nil
155 })
156 return index
157}
158
159func declaredTypes(path string) []string {
160 file, err := parser.ParseFile(token.NewFileSet(), path, nil, 0)
161 if err != nil {
162 return nil
163 }
164 var names []string
165 for _, decl := range file.Decls {
166 gen, ok := decl.(*ast.GenDecl)
167 if !ok || gen.Tok != token.TYPE {
168 continue
169 }
170 for _, spec := range gen.Specs {
171 ts, ok := spec.(*ast.TypeSpec)
172 if ok {
173 names = append(names, ts.Name.Name)
174 }
175 }
176 }
177 return names
178}
179
180func (r runner) expectedMockNames(interfaceName string) []string {
181 patterns := r.cfg.Mocking.GeneratedMockNamePatterns
182 if len(patterns) == 0 {
183 patterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
184 }
185 out := make([]string, 0, len(patterns))
186 for _, pattern := range patterns {
187 out = append(out, strings.ReplaceAll(pattern, "{{Interface}}", interfaceName))
188 }
189 return out
190}
191
192func matchesAnyGlob(patterns []string, path string) bool {
193 for _, pattern := range patterns {
194 if glob.Match(pattern, path) {
195 return true
196 }
197 }
198 return false
199}
200
201func isTestDoubleName(name string) bool {
202 return hasWordPrefix(name, "fake") || hasWordSuffix(name, "fake") || hasWordPrefix(name, "stub") || hasWordSuffix(name, "stub")
203}
204
205func hasWordPrefix(name, prefix string) bool {
206 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
207 return false
208 }
209 return startsCamelWord(name[len(prefix):])
210}
211
212func hasWordSuffix(name, suffix string) bool {
213 if len(name) < len(suffix) || !strings.EqualFold(name[len(name)-len(suffix):], suffix) {
214 return false
215 }
216 prefix := name[:len(name)-len(suffix)]
217 return prefix == "" || strings.HasSuffix(prefix, "_")
218}
219
220func startsCamelWord(s string) bool {
221 if s == "" {
222 return true
223 }
224 r := rune(s[0])
225 return r == '_' || (r >= 'A' && r <= 'Z')
226}