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 businessCap *businessDiagnosticCap
181}
182
183type businessDiagnosticCap struct {
184 reported int
185 hidden int
186 pos token.Pos
187 filePath string
188}
189
190func (r runner) run() {
191 pkgRel := r.relImportPath(r.pass.Pkg.Path())
192 current, ok := r.cfg.ComponentForPath(pkgRel)
193 if !ok || current.Role == config.RoleIgnore {
194 return
195 }
196 r.ignores = ignoreIndex{}
197 r.businessCap = &businessDiagnosticCap{}
198 for _, file := range r.pass.Files {
199 filePath := r.filePath(file)
200 if isGeneratedFile(file) {
201 continue
202 }
203 r.collectIgnores(file)
204 r.checkImports(file, filePath, current)
205 r.checkTypeLeaks(file, filePath, current)
206 r.checkMissingMocks(file, filePath, current)
207 r.checkBusinessLogic(file, filePath, current)
208 r.checkMocks(file, filePath, current)
209 }
210 if cap := r.businessCap; cap.hidden > 0 {
211 r.report(cap.pos, "business-logic-diagnostics-hidden", cap.filePath, "%d more suspicious-business-logic-in-adapter diagnostics hidden by heuristics.businessLogicMaxDiagnosticsPerPackage", cap.hidden)
212 }
213 r.reportUnusedIgnores()
214}
215
216func (r runner) checkImports(file *ast.File, filePath string, current config.Match) {
217 for _, imp := range file.Imports {
218 importPath, _ := strconv.Unquote(imp.Path.Value)
219 importRel := r.relImportPath(importPath)
220 imported, ok := r.cfg.ComponentForPath(importRel)
221 if !ok || imported.Role == config.RoleIgnore {
222 continue
223 }
224 switch {
225 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
226 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)
227 case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
228 r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
229 case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
230 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)
231 case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
232 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)
233 case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
234 r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
235 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
236 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
237 }
238 }
239}
240
241func (r runner) checkTypeLeaks(file *ast.File, filePath string, current config.Match) {
242 if current.Role != config.RolePorts && current.Role != config.RoleCore {
243 return
244 }
245 ast.Inspect(file, func(n ast.Node) bool {
246 switch node := n.(type) {
247 case *ast.FuncDecl:
248 if current.Role == config.RoleCore && node.Name.IsExported() {
249 r.checkFieldList(node.Type.Params, filePath, "no-framework-types-in-core")
250 r.checkFieldList(node.Type.Results, filePath, "no-framework-types-in-core")
251 }
252 case *ast.TypeSpec:
253 if current.Role == config.RolePorts {
254 if iface, ok := node.Type.(*ast.InterfaceType); ok {
255 for _, method := range iface.Methods.List {
256 if fn, ok := method.Type.(*ast.FuncType); ok {
257 r.checkFieldList(fn.Params, filePath, "no-infra-types-in-ports")
258 r.checkFieldList(fn.Results, filePath, "no-infra-types-in-ports")
259 }
260 }
261 }
262 }
263 if current.Role == config.RoleCore && node.Name.IsExported() {
264 if st, ok := node.Type.(*ast.StructType); ok {
265 r.checkFieldList(st.Fields, filePath, "no-framework-types-in-core")
266 }
267 }
268 }
269 return true
270 })
271}
272
273func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
274 if fields == nil {
275 return
276 }
277 for _, field := range fields.List {
278 t := r.pass.TypesInfo.TypeOf(field.Type)
279 if r.isInfraType(t, nil) {
280 r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
281 }
282 }
283}
284
285// isInfraType reports whether t, or any type it is composed of, belongs to an
286// adapter/entrypoint component or a configured external framework package.
287func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
288 if t == nil {
289 return false
290 }
291 if seen[t] {
292 return false
293 }
294 if seen == nil {
295 seen = map[types.Type]bool{}
296 }
297 seen[t] = true
298 switch tt := t.(type) {
299 case *types.Alias:
300 return r.isInfraType(types.Unalias(tt), seen)
301 case *types.Pointer:
302 return r.isInfraType(tt.Elem(), seen)
303 case *types.Slice:
304 return r.isInfraType(tt.Elem(), seen)
305 case *types.Array:
306 return r.isInfraType(tt.Elem(), seen)
307 case *types.Chan:
308 return r.isInfraType(tt.Elem(), seen)
309 case *types.Map:
310 return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
311 case *types.Signature:
312 return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
313 case *types.Tuple:
314 for v := range tt.Variables() {
315 if r.isInfraType(v.Type(), seen) {
316 return true
317 }
318 }
319 return false
320 case *types.Struct:
321 for field := range tt.Fields() {
322 if r.isInfraType(field.Type(), seen) {
323 return true
324 }
325 }
326 return false
327 case *types.Named:
328 for arg := range tt.TypeArgs().Types() {
329 if r.isInfraType(arg, seen) {
330 return true
331 }
332 }
333 obj := tt.Obj()
334 if obj == nil || obj.Pkg() == nil {
335 return false
336 }
337 pkgPath := obj.Pkg().Path()
338 rel := r.relImportPath(pkgPath)
339 if match, ok := r.cfg.ComponentForPath(rel); ok && (match.Role == config.RoleAdapter || match.Role == config.RoleEntrypoint) {
340 return true
341 }
342 return r.matchesExternal(pkgPath) || r.matchesExternal(rel)
343 default:
344 return false
345 }
346}
347
348func (r runner) matchesExternal(pkg string) bool {
349 for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) {
350 if glob.Match(pattern, pkg) || glob.Match(pattern+"/**", pkg) {
351 return true
352 }
353 }
354 return false
355}
356
357// report emits a diagnostic for rule unless it is disabled, allowed by config,
358// excluded by path, or suppressed by an inline ignore directive.
359func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
360 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
361 return false
362 }
363 if rule == "suspicious-business-logic-in-adapter" && r.businessCap != nil {
364 cap := r.businessCap
365 if cap.reported >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
366 cap.hidden++
367 if cap.pos == token.NoPos {
368 cap.pos, cap.filePath = pos, filePath
369 }
370 return false
371 }
372 cap.reported++
373 }
374 r.pass.Report(analysis.Diagnostic{
375 Pos: pos,
376 Category: rule,
377 Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
378 })
379 return true
380}
381
382func (r runner) isExcluded(rule, filePath string) bool {
383 setting, ok := r.cfg.RuleSettings[rule]
384 if !ok {
385 return false
386 }
387 for _, pattern := range setting.ExcludePaths {
388 if glob.Match(pattern, filePath) {
389 return true
390 }
391 }
392 return false
393}
394
395func (r runner) relImportPath(importPath string) string {
396 if r.modulePath != "" {
397 if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
398 return rel
399 }
400 }
401 if r.modulePath != "" && importPath == r.modulePath {
402 return ""
403 }
404 return importPath
405}
406
407func (r runner) filePath(file *ast.File) string {
408 pos := r.pass.Fset.Position(file.Pos()).Filename
409 pos = strings.ReplaceAll(pos, "\\", "/")
410 if idx := strings.LastIndex(pos, "/src/"+r.modulePath+"/"); idx >= 0 && r.modulePath != "" {
411 return pos[idx+len("/src/")+len(r.modulePath)+1:]
412 }
413 if r.cfg.Root != "" {
414 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
415 if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
416 return rel
417 }
418 }
419 return pos
420}