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) {
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 r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role)
41 }
42}
43
44func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string, fileRole config.Role) bool {
45 facts := r.collectBusinessFunctionFacts(fn, fileRole, file)
46 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
47 return false
48 }
49 strong, weak := facts.classify()
50 if facts.isTechnicalDetectionOnly() {
51 return false
52 }
53 confidence := facts.businessLogicConfidence(strong, weak)
54 if confidenceMeets(confidence, effectiveBusinessLogicMinConfidence(r.cfg)) {
55 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))
56 }
57 return false
58}
59
60func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.Role, file ...*ast.File) businessFunctionFacts {
61 if fileRole == "" {
62 fileRole = config.RoleAdapter
63 }
64 facts := businessFunctionFacts{runner: r, fn: fn, fileRole: fileRole, imports: map[string]bool{}}
65 if len(file) > 0 && file[0] != nil {
66 for _, imp := range file[0].Imports {
67 facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
68 }
69 }
70 // Reject oversized bodies before type queries or secondary traversals.
71 ast.Inspect(fn.Body, func(n ast.Node) bool {
72 if n == nil {
73 return false
74 }
75 facts.nodeCount++
76 return facts.nodeCount <= *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes
77 })
78 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
79 return facts
80 }
81 facts.collectLocals()
82 ast.Inspect(fn.Body, facts.inspect)
83 return facts
84}
85
86type businessFunctionFacts struct {
87 runner runner
88 fn *ast.FuncDecl
89 fileRole config.Role
90
91 nodeCount int
92 branchCount int
93 keyword bool
94 domainTypeUse bool
95 domainMutation bool
96 domainDecision bool
97 zeroDecision bool
98 guardedDomainError bool
99 domainMethodCall bool
100 domainConstructorCall bool
101 domainErrorReturn bool
102 policyConstant bool
103 usecaseCalls map[string]bool
104 imports map[string]bool
105 usesTechnicalPackage bool
106 constructedLocals map[types.Object]bool
107}
108
109func (f *businessFunctionFacts) inspect(n ast.Node) bool {
110 if n == nil {
111 return true
112 }
113
114 if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
115 f.domainTypeUse = true
116 }
117
118 switch node := n.(type) {
119 case *ast.IfStmt:
120 f.branchCount++
121 f.inspectDecision(node.Cond)
122 f.inspectMembershipInit(node)
123 f.inspectPolicyError(node)
124 case *ast.SwitchStmt:
125 f.branchCount++
126 f.inspectDecision(node.Tag)
127 case *ast.CaseClause:
128 for _, expr := range node.List {
129 f.inspectDecision(expr)
130 }
131 case *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
132 f.branchCount++
133 case *ast.AssignStmt:
134 for _, lhs := range node.Lhs {
135 if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) && !f.isConstructedLocal(lhs) {
136 f.domainMutation = true
137 }
138 }
139 case *ast.CallExpr:
140 f.inspectCall(node)
141 case *ast.BasicLit:
142 if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
143 f.policyConstant = true
144 }
145 case *ast.ReturnStmt:
146 for _, result := range node.Results {
147 if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) || f.isDomainError(result) {
148 f.domainErrorReturn = true
149 }
150 }
151 }
152 return true
153}
154
155// inspectDecision distinguishes domain decisions from merely transporting domain
156// values. Formatting calls do not become policy evidence in a comparison.
157func (f *businessFunctionFacts) inspectDecision(expr ast.Expr) {
158 if expr == nil {
159 return
160 }
161 ast.Inspect(expr, func(n ast.Node) bool {
162 switch node := n.(type) {
163 case *ast.IndexExpr:
164 // A domain-derived key deciding membership is policy, unlike
165 // formatting a key for a port call.
166 ast.Inspect(node.Index, func(n ast.Node) bool {
167 if call, ok := n.(*ast.CallExpr); ok {
168 if sel, ok := call.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) {
169 f.domainDecision = true
170 }
171 }
172 return true
173 })
174 case *ast.CallExpr:
175 if fn, ok := f.calleeObject(node.Fun).(*types.Func); ok && f.coreObject(fn) {
176 sig := fn.Type().(*types.Signature)
177 if sig.Recv() == nil && sig.Results().Len() == 1 && types.Identical(sig.Results().At(0).Type(), types.Typ[types.Bool]) {
178 f.domainDecision = true
179 }
180 }
181 if sel, ok := node.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) {
182 switch sel.Sel.Name {
183 case "String", "Error":
184 case "IsZero":
185 f.zeroDecision = true
186 default:
187 f.domainDecision = true
188 }
189 }
190 return false
191 case *ast.BinaryExpr:
192 switch node.Op {
193 case token.LSS, token.LEQ, token.GTR, token.GEQ:
194 if f.numericDomainOperand(node.X) || f.numericDomainOperand(node.Y) {
195 f.domainDecision = true
196 }
197 }
198 }
199 return true
200 })
201}
202
203func (f *businessFunctionFacts) isDomainError(expr ast.Expr) bool {
204 sel, ok := expr.(*ast.SelectorExpr)
205 if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") {
206 return false
207 }
208 obj := f.runner.pass.TypesInfo.ObjectOf(sel.Sel)
209 if obj == nil || obj.Pkg() == nil {
210 return false
211 }
212 match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
213 return ok && match.Role == config.RoleCore
214}
215
216func (f *businessFunctionFacts) calleeObject(expr ast.Expr) types.Object {
217 switch expr := expr.(type) {
218 case *ast.Ident:
219 return f.runner.pass.TypesInfo.ObjectOf(expr)
220 case *ast.SelectorExpr:
221 return f.runner.pass.TypesInfo.ObjectOf(expr.Sel)
222 }
223 return nil
224}
225
226func (f *businessFunctionFacts) coreObject(obj types.Object) bool {
227 if obj == nil || obj.Pkg() == nil {
228 return false
229 }
230 match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
231 return ok && match.Role == config.RoleCore
232}
233
234func (f *businessFunctionFacts) numericDomainOperand(expr ast.Expr) bool {
235 t := f.runner.pass.TypesInfo.TypeOf(expr)
236 if t == nil {
237 return false
238 }
239 basic, ok := t.Underlying().(*types.Basic)
240 if !ok || basic.Info()&(types.IsInteger|types.IsFloat) == 0 {
241 return false
242 }
243 switch expr := expr.(type) {
244 case *ast.ParenExpr:
245 return f.numericDomainOperand(expr.X)
246 case *ast.SelectorExpr:
247 return f.exprUsesRole(expr.X, config.RoleCore) || f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr.Sel))
248 case *ast.Ident:
249 return f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr))
250 }
251 return false
252}
253
254func (f *businessFunctionFacts) inspectMembershipInit(stmt *ast.IfStmt) {
255 assign, ok := stmt.Init.(*ast.AssignStmt)
256 if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 {
257 return
258 }
259 index, ok := assign.Rhs[0].(*ast.IndexExpr)
260 if !ok {
261 return
262 }
263 t := f.runner.pass.TypesInfo.TypeOf(index.X)
264 if t == nil {
265 return
266 }
267 if _, ok := t.Underlying().(*types.Map); !ok {
268 return
269 }
270 id, ok := assign.Lhs[1].(*ast.Ident)
271 if !ok || id.Name == "_" {
272 return
273 }
274 obj := f.runner.pass.TypesInfo.ObjectOf(id)
275 // Only follow the comma-ok boolean when it participates in the condition.
276 ast.Inspect(stmt.Cond, func(n ast.Node) bool {
277 if use, ok := n.(*ast.Ident); ok && f.runner.pass.TypesInfo.ObjectOf(use) == obj {
278 f.inspectDecision(index)
279 }
280 return true
281 })
282}
283
284// A guarded domain error is policy evidence unless it is a conventional
285// storage/validation sentinel. Those are routinely translated by adapters.
286func (f *businessFunctionFacts) inspectPolicyError(stmt *ast.IfStmt) {
287 for _, s := range stmt.Body.List {
288 ret, ok := s.(*ast.ReturnStmt)
289 if !ok {
290 continue
291 }
292 for _, result := range ret.Results {
293 sel, ok := result.(*ast.SelectorExpr)
294 if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") {
295 continue
296 }
297 switch sel.Sel.Name {
298 case "ErrNotFound", "ErrConflict", "ErrInvalid", "ErrAlreadyExists":
299 continue
300 }
301 if f.isDomainError(result) {
302 f.domainDecision = true
303 f.guardedDomainError = true
304 }
305 }
306 }
307}
308
309// isConstructedLocal excludes field-by-field decoding into a fresh local
310// value. Values obtained from calls (including ports) remain mutation evidence.
311func (f *businessFunctionFacts) isConstructedLocal(expr ast.Expr) bool {
312 for {
313 sel, ok := expr.(*ast.SelectorExpr)
314 if !ok {
315 break
316 }
317 expr = sel.X
318 }
319 id, ok := expr.(*ast.Ident)
320 if !ok {
321 return false
322 }
323 return f.constructedLocals[f.runner.pass.TypesInfo.ObjectOf(id)]
324}
325
326func (f *businessFunctionFacts) collectLocals() {
327 f.constructedLocals = map[types.Object]bool{}
328 reassigned := map[types.Object]bool{}
329 info := f.runner.pass.TypesInfo
330 ast.Inspect(f.fn.Body, func(n ast.Node) bool {
331 switch node := n.(type) {
332 case *ast.ValueSpec:
333 for i, name := range node.Names {
334 if obj := info.Defs[name]; obj != nil {
335 f.constructedLocals[obj] = len(node.Values) == 0 || (i < len(node.Values) && isFreshValue(node.Values[i]))
336 }
337 }
338 case *ast.AssignStmt:
339 for i, lhs := range node.Lhs {
340 if name, ok := lhs.(*ast.Ident); ok {
341 obj := info.ObjectOf(name)
342 if obj == nil {
343 continue
344 }
345 if info.Defs[name] == obj {
346 f.constructedLocals[obj] = i < len(node.Rhs) && isFreshValue(node.Rhs[i])
347 } else {
348 reassigned[obj] = true
349 }
350 }
351 }
352 }
353 return true
354 })
355 for obj := range reassigned {
356 delete(f.constructedLocals, obj)
357 }
358}
359
360func isFreshValue(expr ast.Expr) bool {
361 if unary, ok := expr.(*ast.UnaryExpr); ok && unary.Op == token.AND {
362 expr = unary.X
363 }
364 _, ok := expr.(*ast.CompositeLit)
365 return ok
366}
367
368func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
369 f.inspectTechnicalCall(call)
370 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
371 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
372 f.domainConstructorCall = true
373 }
374 if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
375 if f.exprUsesRole(sel.X, config.RoleCore) {
376 f.domainMethodCall = true
377 }
378 if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
379 if f.usecaseCalls == nil {
380 f.usecaseCalls = map[string]bool{}
381 }
382 f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
383 }
384 }
385 for _, arg := range call.Args {
386 if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
387 f.domainTypeUse = true
388 }
389 }
390}
391
392func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) {
393 sel, ok := call.Fun.(*ast.SelectorExpr)
394 if !ok {
395 return
396 }
397 ident, ok := sel.X.(*ast.Ident)
398 if !ok || f.runner.pass == nil || f.runner.pass.TypesInfo == nil {
399 return
400 }
401 pkgName, ok := f.runner.pass.TypesInfo.ObjectOf(ident).(*types.PkgName)
402 if !ok || pkgName.Imported() == nil {
403 return
404 }
405 path := pkgName.Imported().Path()
406 if technicalImportPaths[path] && f.imports[path] {
407 f.usesTechnicalPackage = true
408 }
409}
410
411var technicalImportPaths = map[string]bool{
412 "os": true,
413 "path/filepath": true,
414 "strconv": true,
415 "bufio": true,
416}
417
418func collaboratorKey(info *types.Info, expr ast.Expr) string {
419 if ident, ok := expr.(*ast.Ident); ok {
420 if obj := info.ObjectOf(ident); obj != nil {
421 return obj.Pkg().Path() + "." + obj.Name()
422 }
423 }
424 return fmt.Sprintf("%p", expr)
425}
426
427func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence {
428 if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals {
429 return businessLogicConfidenceHigh
430 }
431 if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
432 return businessLogicConfidenceMedium
433 }
434 if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
435 return businessLogicConfidenceLow
436 }
437 return businessLogicConfidenceNone
438}
439
440func effectiveBusinessLogicMinConfidence(cfg *config.Config) string {
441 min := cfg.Heuristics.BusinessLogicMinConfidence
442 if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) {
443 return string(businessLogicConfidenceHigh)
444 }
445 return min
446}
447
448func confidenceMeets(got businessLogicConfidence, min string) bool {
449 levels := map[businessLogicConfidence]int{
450 businessLogicConfidenceNone: 0,
451 businessLogicConfidenceLow: 1,
452 businessLogicConfidenceMedium: 2,
453 businessLogicConfidenceHigh: 3,
454 }
455 want, ok := levels[businessLogicConfidence(min)]
456 if !ok {
457 want = levels[businessLogicConfidenceLow]
458 }
459 return levels[got] >= want
460}
461
462func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool {
463 if !containsWord(f.fn.Name.Name, "Detect") {
464 return false
465 }
466 if !f.usesTechnicalPackage {
467 return false
468 }
469 return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0
470}
471
472func (f *businessFunctionFacts) componentRole() config.Role {
473 return f.fileRole
474}
475
476func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
477 strong := []businessLogicSignal{}
478 weak := []businessLogicSignal{}
479 if len(f.fn.Type.Params.List) > 0 {
480 for _, field := range f.fn.Type.Params.List {
481 if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
482 f.domainTypeUse = true
483 }
484 }
485 }
486 for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
487 if containsWord(f.fn.Name.Name, keyword) {
488 f.keyword = true
489 break
490 }
491 }
492
493 if f.zeroDecision && f.domainMutation {
494 f.domainDecision = true
495 }
496 if f.domainMutation {
497 weak = append(weak, businessLogicSignal{kind: "domain mutation"})
498 }
499 if f.domainMethodCall {
500 weak = append(weak, businessLogicSignal{kind: "domain method call"})
501 }
502 if f.guardedDomainError && f.branchCount >= 2 {
503 weak = append(weak, businessLogicSignal{kind: "guarded domain error"})
504 }
505 if f.domainDecision && f.branchCount >= 2 {
506 strong = append(strong, businessLogicSignal{kind: "domain-driven control flow", strong: true})
507 }
508 if f.keyword && len(f.usecaseCalls) >= 2 {
509 strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
510 }
511 if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
512 strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
513 }
514 if f.keyword && (f.domainDecision || (f.domainMutation && containsWord(f.fn.Name.Name, "Validate")) || (f.domainTypeUse && (containsWord(f.fn.Name.Name, "Compute") || containsWord(f.fn.Name.Name, "Calculate") || containsWord(f.fn.Name.Name, "Score")))) {
515 strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
516 }
517
518 if f.keyword {
519 weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
520 }
521 if f.policyConstant {
522 weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
523 }
524 if f.domainErrorReturn {
525 weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
526 }
527 if len(f.usecaseCalls) >= 2 {
528 weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
529 }
530 if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
531 weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
532 }
533 return strong, weak
534}
535
536func policyKeyword(name string) bool {
537 for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
538 if containsWord(name, word) {
539 return true
540 }
541 }
542 return false
543}
544
545func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
546 if expr == nil {
547 return false
548 }
549 return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
550}
551
552func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
553 sel, ok := expr.(*ast.SelectorExpr)
554 if !ok {
555 return false
556 }
557 return f.exprUsesRole(sel.X, roles...)
558}
559
560func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
561 for _, role := range roles {
562 if f.typeHasRole(t, role) {
563 return true
564 }
565 }
566 return false
567}
568
569func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
570 if t == nil {
571 return false
572 }
573 switch tt := t.(type) {
574 case *types.Pointer:
575 return f.typeHasRole(tt.Elem(), role)
576 case *types.Slice:
577 return f.typeHasRole(tt.Elem(), role)
578 case *types.Array:
579 return f.typeHasRole(tt.Elem(), role)
580 case *types.Map:
581 return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
582 case *types.Signature:
583 return f.typeHasRole(tt.Results(), role)
584 case *types.Tuple:
585 for v := range tt.Variables() {
586 if f.typeHasRole(v.Type(), role) {
587 return true
588 }
589 }
590 return false
591 case *types.Named:
592 obj := tt.Obj()
593 if obj == nil || obj.Pkg() == nil {
594 return false
595 }
596 match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
597 if ok && match.Role == role {
598 return true
599 }
600 return f.typeHasRole(tt.Underlying(), role)
601 default:
602 return false
603 }
604}
605
606func exprFromNode(n ast.Node) ast.Expr {
607 expr, _ := n.(ast.Expr)
608 return expr
609}
610
611func signalKinds(signals []businessLogicSignal) []string {
612 out := make([]string, 0, len(signals))
613 for _, signal := range signals {
614 out = append(out, signal.kind)
615 }
616 return out
617}
618
619func containsWord(name, word string) bool {
620 if word == "" || len(word) > len(name) {
621 return false
622 }
623 for i := 0; i <= len(name)-len(word); i++ {
624 if !strings.EqualFold(name[i:i+len(word)], word) {
625 continue
626 }
627 beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i]))
628 after := i + len(word)
629 afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after]))
630 if beforeOK && afterOK {
631 return true
632 }
633 }
634 return false
635}
636
637func isBoundaryRune(prev, next rune) bool {
638 return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z')
639}