v0.2.0 92ac699

analyzer.go RawHistoryFolder

1package analyzer
2
3import (
4	"go/ast"
5	"go/token"
6	"go/types"
7	"os"
8	"path/filepath"
9	"strconv"
10	"strings"
11
12	"github.com/bnema/hexcheck/config"
13	"github.com/bnema/hexcheck/internal/glob"
14	"golang.org/x/tools/go/analysis"
15)
16
17const Name = "hexcheck"
18
19type Options struct {
20	Config     *config.Config
21	ConfigPath string
22	Root       string
23	ModulePath string
24}
25
26func New(opts Options) *analysis.Analyzer {
27	var configPathFlag string
28	var rootFlag string
29	var modulePathFlag string
30
31	a := &analysis.Analyzer{
32		Name: Name,
33		Doc:  "checks hexagonal architecture boundaries",
34		Run: func(pass *analysis.Pass) (any, error) {
35			cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
36			if err != nil {
37				return nil, err
38			}
39			modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
40			if modulePath == "" {
41				modulePath = discoverModulePath(root)
42			}
43			r := runner{pass: pass, cfg: cfg, modulePath: modulePath}
44			r.run()
45			return nil, nil
46		},
47	}
48	if opts.Config == nil {
49		a.Flags.StringVar(&configPathFlag, "config", opts.ConfigPath, "path to .hexcheck.yaml")
50		a.Flags.StringVar(&rootFlag, "root", opts.Root, "project root for config-relative paths")
51		a.Flags.StringVar(&modulePathFlag, "module", opts.ModulePath, "Go module path; defaults to module in go.mod")
52	}
53	return a
54}
55
56func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
57	if opts.Config != nil {
58		cfg := cloneConfig(opts.Config)
59		cfg.ApplyDefaults()
60		if err := cfg.Validate(); err != nil {
61			return nil, "", err
62		}
63		if cfg.Root != "" {
64			root, err := filepath.Abs(cfg.Root)
65			if err != nil {
66				return nil, "", err
67			}
68			cfg.Root = root
69		}
70		return cfg, cfg.Root, nil
71	}
72	root := firstNonEmpty(opts.Root, rootFlag)
73	if root != "" {
74		absRoot, err := filepath.Abs(root)
75		if err != nil {
76			return nil, "", err
77		}
78		root = absRoot
79	}
80	configPath := firstNonEmpty(opts.ConfigPath, configPathFlag)
81	if configPath == "" {
82		start := root
83		if start == "" {
84			start = "."
85		}
86		discovered, err := config.DiscoverConfig(start)
87		if err == nil {
88			configPath = discovered
89		}
90	}
91	if configPath == "" {
92		cfg := config.Default()
93		if root != "" {
94			cfg.Root = root
95		}
96		return cfg, cfg.Root, nil
97	}
98	if root != "" && !filepath.IsAbs(configPath) {
99		configPath = filepath.Join(root, configPath)
100	}
101	cfg, err := config.Load(configPath)
102	if err != nil {
103		return nil, "", err
104	}
105	if root != "" {
106		cfg.Root = root
107	}
108	return cfg, cfg.Root, nil
109}
110
111func discoverModulePath(root string) string {
112	if root == "" {
113		root = "."
114	}
115	data, err := os.ReadFile(filepath.Join(root, "go.mod"))
116	if err != nil {
117		return ""
118	}
119	for line := range strings.SplitSeq(string(data), "\n") {
120		line = strings.TrimSpace(line)
121		if modulePath, ok := strings.CutPrefix(line, "module "); ok {
122			return strings.TrimSpace(modulePath)
123		}
124	}
125	return ""
126}
127
128func firstNonEmpty(values ...string) string {
129	for _, value := range values {
130		if value != "" {
131			return value
132		}
133	}
134	return ""
135}
136
137type runner struct {
138	pass       *analysis.Pass
139	cfg        *config.Config
140	modulePath string
141}
142
143func (r runner) run() {
144	pkgRel := r.relImportPath(r.pass.Pkg.Path())
145	current, ok := r.cfg.ComponentForPath(pkgRel)
146	if !ok || current.Role == config.RoleIgnore {
147		return
148	}
149	businessDiagnostics := 0
150	for _, file := range r.pass.Files {
151		filePath := r.filePath(file)
152		if isGeneratedFile(file) {
153			continue
154		}
155		r.checkImports(file, filePath, current)
156		r.checkTypeLeaks(file, filePath, current)
157		r.checkMissingMocks(file, filePath, current)
158		r.checkBusinessLogic(file, filePath, current, &businessDiagnostics)
159		r.checkMocks(file, filePath, current)
160	}
161}
162
163func (r runner) checkImports(file *ast.File, filePath string, current config.Match) {
164	for _, imp := range file.Imports {
165		importPath, _ := strconv.Unquote(imp.Path.Value)
166		importRel := r.relImportPath(importPath)
167		imported, ok := r.cfg.ComponentForPath(importRel)
168		if !ok || imported.Role == config.RoleIgnore {
169			continue
170		}
171		switch {
172		case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
173			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)
174		case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:
175			r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
176		case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
177			r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
178		}
179	}
180}
181
182func (r runner) checkTypeLeaks(file *ast.File, filePath string, current config.Match) {
183	if current.Role != config.RolePorts && current.Role != config.RoleCore {
184		return
185	}
186	ast.Inspect(file, func(n ast.Node) bool {
187		switch node := n.(type) {
188		case *ast.FuncDecl:
189			if current.Role == config.RoleCore && node.Name.IsExported() {
190				r.checkFieldList(node.Type.Params, filePath, "no-framework-types-in-core")
191				r.checkFieldList(node.Type.Results, filePath, "no-framework-types-in-core")
192			}
193		case *ast.TypeSpec:
194			if current.Role == config.RolePorts {
195				if iface, ok := node.Type.(*ast.InterfaceType); ok {
196					for _, method := range iface.Methods.List {
197						if fn, ok := method.Type.(*ast.FuncType); ok {
198							r.checkFieldList(fn.Params, filePath, "no-infra-types-in-ports")
199							r.checkFieldList(fn.Results, filePath, "no-infra-types-in-ports")
200						}
201					}
202				}
203			}
204			if current.Role == config.RoleCore && node.Name.IsExported() {
205				if st, ok := node.Type.(*ast.StructType); ok {
206					r.checkFieldList(st.Fields, filePath, "no-framework-types-in-core")
207				}
208			}
209		}
210		return true
211	})
212}
213
214func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
215	if fields == nil {
216		return
217	}
218	for _, field := range fields.List {
219		if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {
220			r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))
221		}
222	}
223}
224
225func (r runner) isInfraType(t types.Type) bool {
226	if t == nil {
227		return false
228	}
229	switch tt := t.(type) {
230	case *types.Pointer:
231		return r.isInfraType(tt.Elem())
232	case *types.Slice:
233		return r.isInfraType(tt.Elem())
234	case *types.Array:
235		return r.isInfraType(tt.Elem())
236	case *types.Map:
237		return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())
238	case *types.Named:
239		obj := tt.Obj()
240		if obj == nil || obj.Pkg() == nil {
241			return false
242		}
243		pkgPath := obj.Pkg().Path()
244		rel := r.relImportPath(pkgPath)
245		if match, ok := r.cfg.ComponentForPath(rel); ok && (match.Role == config.RoleAdapter || match.Role == config.RoleEntrypoint) {
246			return true
247		}
248		return r.matchesExternal(pkgPath) || r.matchesExternal(rel)
249	default:
250		return false
251	}
252}
253
254func (r runner) matchesExternal(pkg string) bool {
255	for _, pattern := range append(r.cfg.ExternalTypes.FrameworkPackages, r.cfg.ExternalTypes.AdapterTypePackages...) {
256		if glob.Match(pattern, pkg) || glob.Match(pattern+"/**", pkg) {
257			return true
258		}
259	}
260	return false
261}
262
263func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
264	if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {
265		return false
266	}
267	r.pass.Reportf(pos, rule+": "+format, args...)
268	return true
269}
270
271func (r runner) isExcluded(rule, filePath string) bool {
272	setting, ok := r.cfg.RuleSettings[rule]
273	if !ok {
274		return false
275	}
276	for _, pattern := range setting.ExcludePaths {
277		if glob.Match(pattern, filePath) {
278			return true
279		}
280	}
281	return false
282}
283
284func (r runner) relImportPath(importPath string) string {
285	if r.modulePath != "" {
286		if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
287			return rel
288		}
289	}
290	if r.modulePath != "" && importPath == r.modulePath {
291		return ""
292	}
293	return importPath
294}
295
296func (r runner) filePath(file *ast.File) string {
297	pos := r.pass.Fset.Position(file.Pos()).Filename
298	pos = strings.ReplaceAll(pos, "\\", "/")
299	if idx := strings.LastIndex(pos, "/src/"+r.modulePath+"/"); idx >= 0 && r.modulePath != "" {
300		return pos[idx+len("/src/")+len(r.modulePath)+1:]
301	}
302	if r.cfg.Root != "" {
303		root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
304		if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
305			return rel
306		}
307	}
308	return pos
309}

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