v0.3.1 1e04dbb

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}
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}

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