1package analyzer
2
3import (
4 "fmt"
5 "go/ast"
6 "go/token"
7 "go/types"
8 "strings"
9
10 "github.com/bnema/hexcheck/config"
11)
12
13type businessLogicConfidence string
14
15const (
16 businessLogicConfidenceNone businessLogicConfidence = "none"
17 businessLogicConfidenceLow businessLogicConfidence = "low"
18 businessLogicConfidenceMedium businessLogicConfidence = "medium"
19 businessLogicConfidenceHigh businessLogicConfidence = "high"
20)
21
22type businessLogicSignal struct {
23 kind string
24 strong bool
25}
26
27func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) {
28 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
29 return
30 }
31 if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") {
32 return
33 }
34
35 for _, decl := range file.Decls {
36 fn, ok := decl.(*ast.FuncDecl)
37 if !ok || fn.Body == nil {
38 continue
39 }
40 if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
41 return
42 }
43 if r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role) {
44 *packageDiagnostics++
45 }
46 }
47}
48
49func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string, fileRole config.Role) bool {
50 facts := r.collectBusinessFunctionFacts(fn, fileRole, file)
51 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
52 return false
53 }
54 strong, weak := facts.classify()
55 if facts.isTechnicalDetectionOnly() {
56 return false
57 }
58 confidence := facts.businessLogicConfidence(strong, weak)
59 if confidenceMeets(confidence, effectiveBusinessLogicMinConfidence(r.cfg)) {
60 return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "%s function %s has business-logic evidence: confidence=%s strong=%v weak=%v", facts.componentRole(), fn.Name.Name, confidence, signalKinds(strong), signalKinds(weak))
61 }
62 return false
63}
64
65func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.Role, file ...*ast.File) businessFunctionFacts {
66 if fileRole == "" {
67 fileRole = config.RoleAdapter
68 }
69 facts := businessFunctionFacts{runner: r, fn: fn, fileRole: fileRole, imports: map[string]bool{}}
70 if len(file) > 0 && file[0] != nil {
71 for _, imp := range file[0].Imports {
72 facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
73 }
74 }
75 ast.Inspect(fn.Body, facts.inspect)
76 return facts
77}
78
79type businessFunctionFacts struct {
80 runner runner
81 fn *ast.FuncDecl
82 fileRole config.Role
83
84 nodeCount int
85 branchCount int
86 keyword bool
87 domainTypeUse bool
88 domainMutation bool
89 domainMethodCall bool
90 domainConstructorCall bool
91 domainErrorReturn bool
92 policyConstant bool
93 usecaseCalls map[string]bool
94 imports map[string]bool
95 usesTechnicalPackage bool
96}
97
98func (f *businessFunctionFacts) inspect(n ast.Node) bool {
99 if n == nil {
100 return true
101 }
102 f.nodeCount++
103 if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
104 return false
105 }
106
107 if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
108 f.domainTypeUse = true
109 }
110
111 switch node := n.(type) {
112 case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
113 f.branchCount++
114 case *ast.AssignStmt:
115 for _, lhs := range node.Lhs {
116 if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) {
117 f.domainMutation = true
118 }
119 }
120 case *ast.CallExpr:
121 f.inspectCall(node)
122 case *ast.BasicLit:
123 if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
124 f.policyConstant = true
125 }
126 case *ast.ReturnStmt:
127 for _, result := range node.Results {
128 if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) {
129 f.domainErrorReturn = true
130 }
131 }
132 }
133 return true
134}
135
136func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
137 f.inspectTechnicalCall(call)
138 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
139 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
140 f.domainConstructorCall = true
141 }
142 if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
143 if f.exprUsesRole(sel.X, config.RoleCore) {
144 f.domainMethodCall = true
145 }
146 if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
147 if f.usecaseCalls == nil {
148 f.usecaseCalls = map[string]bool{}
149 }
150 f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
151 }
152 }
153 for _, arg := range call.Args {
154 if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
155 f.domainTypeUse = true
156 }
157 }
158}
159
160func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) {
161 sel, ok := call.Fun.(*ast.SelectorExpr)
162 if !ok {
163 return
164 }
165 ident, ok := sel.X.(*ast.Ident)
166 if !ok || f.runner.pass == nil || f.runner.pass.TypesInfo == nil {
167 return
168 }
169 pkgName, ok := f.runner.pass.TypesInfo.ObjectOf(ident).(*types.PkgName)
170 if !ok || pkgName.Imported() == nil {
171 return
172 }
173 path := pkgName.Imported().Path()
174 if technicalImportPaths[path] && f.imports[path] {
175 f.usesTechnicalPackage = true
176 }
177}
178
179var technicalImportPaths = map[string]bool{
180 "os": true,
181 "path/filepath": true,
182 "strconv": true,
183 "bufio": true,
184}
185
186func collaboratorKey(info *types.Info, expr ast.Expr) string {
187 if ident, ok := expr.(*ast.Ident); ok {
188 if obj := info.ObjectOf(ident); obj != nil {
189 return obj.Pkg().Path() + "." + obj.Name()
190 }
191 }
192 return fmt.Sprintf("%p", expr)
193}
194
195func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence {
196 if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals {
197 return businessLogicConfidenceHigh
198 }
199 if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
200 return businessLogicConfidenceMedium
201 }
202 if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
203 return businessLogicConfidenceLow
204 }
205 return businessLogicConfidenceNone
206}
207
208func effectiveBusinessLogicMinConfidence(cfg *config.Config) string {
209 min := cfg.Heuristics.BusinessLogicMinConfidence
210 if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) {
211 return string(businessLogicConfidenceHigh)
212 }
213 return min
214}
215
216func confidenceMeets(got businessLogicConfidence, min string) bool {
217 levels := map[businessLogicConfidence]int{
218 businessLogicConfidenceNone: 0,
219 businessLogicConfidenceLow: 1,
220 businessLogicConfidenceMedium: 2,
221 businessLogicConfidenceHigh: 3,
222 }
223 want, ok := levels[businessLogicConfidence(min)]
224 if !ok {
225 want = levels[businessLogicConfidenceLow]
226 }
227 return levels[got] >= want
228}
229
230func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool {
231 if !containsWord(f.fn.Name.Name, "Detect") {
232 return false
233 }
234 if !f.usesTechnicalPackage {
235 return false
236 }
237 return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0
238}
239
240func (f *businessFunctionFacts) componentRole() config.Role {
241 return f.fileRole
242}
243
244func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
245 strong := []businessLogicSignal{}
246 weak := []businessLogicSignal{}
247 if len(f.fn.Type.Params.List) > 0 {
248 for _, field := range f.fn.Type.Params.List {
249 if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
250 f.domainTypeUse = true
251 }
252 }
253 }
254 for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
255 if containsWord(f.fn.Name.Name, keyword) {
256 f.keyword = true
257 break
258 }
259 }
260
261 if f.domainMutation {
262 strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true})
263 }
264 if f.domainMethodCall {
265 strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true})
266 }
267 if f.keyword && len(f.usecaseCalls) >= 2 {
268 strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
269 }
270 if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
271 strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
272 }
273 if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) {
274 strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
275 }
276
277 if f.keyword {
278 weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
279 }
280 if f.policyConstant {
281 weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
282 }
283 if f.domainErrorReturn {
284 weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
285 }
286 if len(f.usecaseCalls) >= 2 {
287 weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
288 }
289 if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
290 weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
291 }
292 return strong, weak
293}
294
295func policyKeyword(name string) bool {
296 for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
297 if containsWord(name, word) {
298 return true
299 }
300 }
301 return false
302}
303
304func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
305 if expr == nil {
306 return false
307 }
308 return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
309}
310
311func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
312 sel, ok := expr.(*ast.SelectorExpr)
313 if !ok {
314 return false
315 }
316 return f.exprUsesRole(sel.X, roles...)
317}
318
319func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
320 for _, role := range roles {
321 if f.typeHasRole(t, role) {
322 return true
323 }
324 }
325 return false
326}
327
328func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
329 if t == nil {
330 return false
331 }
332 switch tt := t.(type) {
333 case *types.Pointer:
334 return f.typeHasRole(tt.Elem(), role)
335 case *types.Slice:
336 return f.typeHasRole(tt.Elem(), role)
337 case *types.Array:
338 return f.typeHasRole(tt.Elem(), role)
339 case *types.Map:
340 return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
341 case *types.Signature:
342 return f.typeHasRole(tt.Results(), role)
343 case *types.Tuple:
344 for v := range tt.Variables() {
345 if f.typeHasRole(v.Type(), role) {
346 return true
347 }
348 }
349 return false
350 case *types.Named:
351 obj := tt.Obj()
352 if obj == nil || obj.Pkg() == nil {
353 return false
354 }
355 match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
356 if ok && match.Role == role {
357 return true
358 }
359 return f.typeHasRole(tt.Underlying(), role)
360 default:
361 return false
362 }
363}
364
365func exprFromNode(n ast.Node) ast.Expr {
366 expr, _ := n.(ast.Expr)
367 return expr
368}
369
370func signalKinds(signals []businessLogicSignal) []string {
371 out := make([]string, 0, len(signals))
372 for _, signal := range signals {
373 out = append(out, signal.kind)
374 }
375 return out
376}
377
378func containsWord(name, word string) bool {
379 if word == "" || len(word) > len(name) {
380 return false
381 }
382 for i := 0; i <= len(name)-len(word); i++ {
383 if !strings.EqualFold(name[i:i+len(word)], word) {
384 continue
385 }
386 beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i]))
387 after := i + len(word)
388 afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after]))
389 if beforeOK && afterOK {
390 return true
391 }
392 }
393 return false
394}
395
396func isBoundaryRune(prev, next rune) bool {
397 return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z')
398}