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}