1package analyzer
2
3import (
4 "fmt"
5 "go/ast"
6 "go/token"
7 "go/types"
8 "os"
9 "path/filepath"
10 "reflect"
11 "strconv"
12 "strings"
13 "sync"
14
15 "github.com/bnema/hexcheck/config"
16 "github.com/bnema/hexcheck/internal/glob"
17 "golang.org/x/tools/go/analysis"
18)
19
20const Name = "hexcheck"
21
22type Options struct {
23 Config *config.Config
24 ConfigPath string
25 Root string
26 ModulePath string
27}
28
29// Result is returned for each analyzed package. Drivers use it to map a
30// diagnostic Category (the rule name) to its configured severity.
31type Result struct {
32 Severities map[string]config.Severity
33}
34
35// Severity returns the configured severity of a diagnostic category.
36func (r *Result) Severity(rule string) config.Severity {
37 if r == nil {
38 return config.SeverityOff
39 }
40 return r.Severities[rule]
41}
42
43// state is shared by every package analyzed by one Analyzer instance, so the
44// config and mock index are loaded once per run instead of once per package.
45type state struct {
46 once sync.Once
47 cfg *config.Config
48 modulePath string
49 err error
50 mockOnce sync.Once
51 mocks map[string]bool
52}
53
54func New(opts Options) *analysis.Analyzer {
55 var configPathFlag string
56 var rootFlag string
57 var modulePathFlag string
58 st := &state{}
59
60 a := &analysis.Analyzer{
61 Name: Name,
62 Doc: "checks hexagonal architecture boundaries",
63 ResultType: reflect.TypeFor[*Result](),
64 Run: func(pass *analysis.Pass) (any, error) {
65 st.once.Do(func() {
66 cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
67 if err != nil {
68 st.err = err
69 return
70 }
71 modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
72 if modulePath == "" {
73 modulePath = discoverModulePath(root)
74 }
75 st.cfg, st.modulePath = cfg, modulePath
76 })
77 if st.err != nil {
78 return nil, st.err
79 }
80 r := runner{pass: pass, cfg: st.cfg, modulePath: st.modulePath, state: st}
81 r.run()
82 return &Result{Severities: st.cfg.Rules}, nil
83 },
84 }
85 if opts.Config == nil {
86 a.Flags.StringVar(&configPathFlag, "config", opts.ConfigPath, "path to .hexcheck.yaml")
87 a.Flags.StringVar(&rootFlag, "root", opts.Root, "project root for config-relative paths")
88 a.Flags.StringVar(&modulePathFlag, "module", opts.ModulePath, "Go module path; defaults to module in go.mod")
89 }
90 return a
91}
92
93func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
94 if opts.Config != nil {
95 cfg := cloneConfig(opts.Config)
96 cfg.ApplyDefaults()
97 if err := cfg.Validate(); err != nil {
98 return nil, "", err
99 }
100 if cfg.Root != "" {
101 root, err := filepath.Abs(cfg.Root)
102 if err != nil {
103 return nil, "", err
104 }
105 cfg.Root = root
106 }
107 return cfg, cfg.Root, nil
108 }
109 root := firstNonEmpty(opts.Root, rootFlag)
110 if root != "" {
111 absRoot, err := filepath.Abs(root)
112 if err != nil {
113 return nil, "", err
114 }
115 root = absRoot
116 }
117 configPath := firstNonEmpty(opts.ConfigPath, configPathFlag)
118 if configPath == "" {
119 start := root
120 if start == "" {
121 start = "."
122 }
123 discovered, err := config.DiscoverConfig(start)
124 if err == nil {
125 configPath = discovered
126 }
127 }
128 if configPath == "" {
129 cfg := config.Default()
130 if root != "" {
131 cfg.Root = root
132 }
133 return cfg, cfg.Root, nil
134 }
135 if root != "" && !filepath.IsAbs(configPath) {
136 configPath = filepath.Join(root, configPath)
137 }
138 cfg, err := config.Load(configPath)
139 if err != nil {
140 return nil, "", err
141 }
142 if root != "" {
143 cfg.Root = root
144 }
145 return cfg, cfg.Root, nil
146}
147
148func discoverModulePath(root string) string {
149 if root == "" {
150 root = "."
151 }
152 data, err := os.ReadFile(filepath.Join(root, "go.mod"))
153 if err != nil {
154 return ""
155 }
156 for line := range strings.SplitSeq(string(data), "\n") {
157 line = strings.TrimSpace(line)
158 if modulePath, ok := strings.CutPrefix(line, "module "); ok {
159 return strings.TrimSpace(modulePath)
160 }
161 }
162 return ""
163}
164
165func firstNonEmpty(values ...string) string {
166 for _, value := range values {
167 if value != "" {
168 return value
169 }
170 }
171 return ""
172}
173
174type runner struct {
175 pass *analysis.Pass
176 cfg *config.Config
177 modulePath string
178 state *state
179 ignores ignoreIndex
180}
181
182func (r runner) run() {
183 pkgRel := r.relImportPath(r.pass.Pkg.Path())
184 current, ok := r.cfg.ComponentForPath(pkgRel)
185 if !ok || current.Role == config.RoleIgnore {
186 return
187 }
188 r.ignores = ignoreIndex{}
189 businessDiagnostics := 0
190 for _, file := range r.pass.Files {
191 filePath := r.filePath(file)
192 if isGeneratedFile(file) {
193 continue
194 }
195 r.collectIgnores(file)
196 r.checkImports(file, filePath, current)
197 r.checkTypeLeaks(file, filePath, current)
198 r.checkMissingMocks(file, filePath, current)
199 r.checkBusinessLogic(file, filePath, current, &businessDiagnostics)
200 r.checkMocks(file, filePath, current)
201 }
202}
203
204func (r runner) checkImports(file *ast.File, filePath string, current config.Match) {
205 for _, imp := range file.Imports {
206 importPath, _ := strconv.Unquote(imp.Path.Value)
207 importRel := r.relImportPath(importPath)
208 imported, ok := r.cfg.ComponentForPath(importRel)
209 if !ok || imported.Role == config.RoleIgnore {
210 continue
211 }
212 switch {
213 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
214 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
215 case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
216 r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
217 case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
218 r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
219 case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
220 r.report(imp.Pos(), "no-infra-imports-in-ports", filePath, "ports component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
221 case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
222 r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
223 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
224 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
225 }
226 }
227}
228
229func (r runner) checkTypeLeaks(file *ast.File, filePath string, current config.Match) {
230 if current.Role != config.RolePorts && current.Role != config.RoleCore {
231 return
232 }
233 ast.Inspect(file, func(n ast.Node) bool {
234 switch node := n.(type) {
235 case *ast.FuncDecl:
236 if current.Role == config.RoleCore && node.Name.IsExported() {
237 r.checkFieldList(node.Type.Params, filePath, "no-framework-types-in-core")
238 r.checkFieldList(node.Type.Results, filePath, "no-framework-types-in-core")
239 }
240 case *ast.TypeSpec:
241 if current.Role == config.RolePorts {
242 if iface, ok := node.Type.(*ast.InterfaceType); ok {
243 for _, method := range iface.Methods.List {
244 if fn, ok := method.Type.(*ast.FuncType); ok {
245 r.checkFieldList(fn.Params, filePath, "no-infra-types-in-ports")
246 r.checkFieldList(fn.Results, filePath, "no-infra-types-in-ports")
247 }
248 }
249 }
250 }
251 if current.Role == config.RoleCore && node.Name.IsExported() {
252 if st, ok := node.Type.(*ast.StructType); ok {
253 r.checkFieldList(st.Fields, filePath, "no-framework-types-in-core")
254 }
255 }
256 }
257 return true
258 })
259}
260
261func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
262 if fields == nil {
263 return
264 }
265 for _, field := range fields.List {
266 t := r.pass.TypesInfo.TypeOf(field.Type)
267 if r.isInfraType(t, nil) {
268 r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
269 }
270 }
271}
272
273// isInfraType reports whether t, or any type it is composed of, belongs to an
274// adapter/entrypoint component or a configured external framework package.
275func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
276 if t == nil {
277 return false
278 }
279 if seen[t] {
280 return false
281 }
282 if seen == nil {
283 seen = map[types.Type]bool{}
284 }
285 seen[t] = true
286 switch tt := t.(type) {
287 case *types.Alias:
288 return r.isInfraType(types.Unalias(tt), seen)
289 case *types.Pointer:
290 return r.isInfraType(tt.Elem(), seen)
291 case *types.Slice:
292 return r.isInfraType(tt.Elem(), seen)
293 case *types.Array:
294 return r.isInfraType(tt.Elem(), seen)
295 case *types.Chan:
296 return r.isInfraType(tt.Elem(), seen)
297 case *types.Map:
298 return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
299 case *types.Signature:
300 return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
301 case *types.Tuple:
302 for v := range tt.Variables() {
303 if r.isInfraType(v.Type(), seen) {
304 return true
305 }
306 }
307 return false
308 case *types.Struct:
309 for field := range tt.Fields() {
310 if r.isInfraType(field.Type(), seen) {
311 return true
312 }
313 }
314 return false
315 case *types.Named:
316 for arg := range tt.TypeArgs().Types() {
317 if r.isInfraType(arg, seen) {
318 return true
319 }
320 }
321 obj := tt.Obj()
322 if obj == nil || obj.Pkg() == nil {
323 return false
324 }
325 pkgPath := obj.Pkg().Path()
326 rel := r.relImportPath(pkgPath)
327 if match, ok := r.cfg.ComponentForPath(rel); ok && (match.Role == config.RoleAdapter || match.Role == config.RoleEntrypoint) {
328 return true
329 }
330 return r.matchesExternal(pkgPath) || r.matchesExternal(rel)
331 default:
332 return false
333 }
334}
335
336func (r runner) matchesExternal(pkg string) bool {
337 for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) {
338 if glob.Match(pattern, pkg) || glob.Match(pattern+"/**", pkg) {
339 return true
340 }
341 }
342 return false
343}
344
345// report emits a diagnostic for rule unless it is disabled, allowed by config,
346// excluded by path, or suppressed by an inline ignore directive.
347func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
348 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
349 return false
350 }
351 r.pass.Report(analysis.Diagnostic{
352 Pos: pos,
353 Category: rule,
354 Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
355 })
356 return true
357}
358
359func (r runner) isExcluded(rule, filePath string) bool {
360 setting, ok := r.cfg.RuleSettings[rule]
361 if !ok {
362 return false
363 }
364 for _, pattern := range setting.ExcludePaths {
365 if glob.Match(pattern, filePath) {
366 return true
367 }
368 }
369 return false
370}
371
372func (r runner) relImportPath(importPath string) string {
373 if r.modulePath != "" {
374 if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
375 return rel
376 }
377 }
378 if r.modulePath != "" && importPath == r.modulePath {
379 return ""
380 }
381 return importPath
382}
383
384func (r runner) filePath(file *ast.File) string {
385 pos := r.pass.Fset.Position(file.Pos()).Filename
386 pos = strings.ReplaceAll(pos, "\\", "/")
387 if idx := strings.LastIndex(pos, "/src/"+r.modulePath+"/"); idx >= 0 && r.modulePath != "" {
388 return pos[idx+len("/src/")+len(r.modulePath)+1:]
389 }
390 if r.cfg.Root != "" {
391 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
392 if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
393 return rel
394 }
395 }
396 return pos
397}