main ae76d4b

analyzer.go RawHistoryFolder

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}

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