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 businessLogicSignal struct {
14 kind string
15 strong bool
16}
17
18func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) {
19 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
20 return
21 }
22 if r.cfg.Heuristics.ExcludeTestFiles != nil && *r.cfg.Heuristics.ExcludeTestFiles && strings.HasSuffix(filePath, "_test.go") {
23 return
24 }
25
26 for _, decl := range file.Decls {
27 fn, ok := decl.(*ast.FuncDecl)
28 if !ok || fn.Body == nil {
29 continue
30 }
31 if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
32 return
33 }
34 if r.isSuspiciousBusinessFunction(fn, filePath) {
35 *packageDiagnostics++
36 }
37 }
38}
39
40func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string) bool {
41 facts := businessFunctionFacts{runner: r, fn: fn}
42 ast.Inspect(fn.Body, facts.inspect)
43 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
44 return false
45 }
46 strong, weak := facts.classify()
47 if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) {
48 return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has business-logic evidence: strong=%v weak=%v", fn.Name.Name, signalKinds(strong), signalKinds(weak))
49 }
50 return false
51}
52
53type businessFunctionFacts struct {
54 runner runner
55 fn *ast.FuncDecl
56
57 nodeCount int
58 branchCount int
59 keyword bool
60 domainTypeUse bool
61 domainMutation bool
62 domainMethodCall bool
63 domainConstructorCall bool
64 domainErrorReturn bool
65 policyConstant bool
66 usecaseCalls map[string]bool
67}
68
69func (f *businessFunctionFacts) inspect(n ast.Node) bool {
70 if n == nil {
71 return true
72 }
73 f.nodeCount++
74 if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
75 return false
76 }
77
78 if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
79 f.domainTypeUse = true
80 }
81
82 switch node := n.(type) {
83 case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
84 f.branchCount++
85 case *ast.AssignStmt:
86 for _, lhs := range node.Lhs {
87 if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) {
88 f.domainMutation = true
89 }
90 }
91 case *ast.CallExpr:
92 f.inspectCall(node)
93 case *ast.BasicLit:
94 if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
95 f.policyConstant = true
96 }
97 case *ast.ReturnStmt:
98 for _, result := range node.Results {
99 if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) {
100 f.domainErrorReturn = true
101 }
102 }
103 }
104 return true
105}
106
107func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
108 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
109 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
110 f.domainConstructorCall = true
111 }
112 if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
113 if f.exprUsesRole(sel.X, config.RoleCore) {
114 f.domainMethodCall = true
115 }
116 if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
117 if f.usecaseCalls == nil {
118 f.usecaseCalls = map[string]bool{}
119 }
120 f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
121 }
122 }
123 for _, arg := range call.Args {
124 if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
125 f.domainTypeUse = true
126 }
127 }
128}
129
130func collaboratorKey(info *types.Info, expr ast.Expr) string {
131 if ident, ok := expr.(*ast.Ident); ok {
132 if obj := info.ObjectOf(ident); obj != nil {
133 return obj.Pkg().Path() + "." + obj.Name()
134 }
135 }
136 return fmt.Sprintf("%p", expr)
137}
138
139func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140 strong := []businessLogicSignal{}
141 weak := []businessLogicSignal{}
142 if len(f.fn.Type.Params.List) > 0 {
143 for _, field := range f.fn.Type.Params.List {
144 if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
145 f.domainTypeUse = true
146 }
147 }
148 }
149 for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
150 if containsWord(f.fn.Name.Name, keyword) {
151 f.keyword = true
152 break
153 }
154 }
155
156 if f.domainMutation {
157 strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true})
158 }
159 if f.domainMethodCall {
160 strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true})
161 }
162 if f.keyword && len(f.usecaseCalls) >= 2 {
163 strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
164 }
165 if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
166 strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
167 }
168 if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) {
169 strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
170 }
171
172 if f.keyword {
173 weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
174 }
175 if f.policyConstant {
176 weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
177 }
178 if f.domainErrorReturn {
179 weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
180 }
181 if len(f.usecaseCalls) >= 2 {
182 weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
183 }
184 if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
185 weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
186 }
187 return strong, weak
188}
189
190func policyKeyword(name string) bool {
191 for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
192 if containsWord(name, word) {
193 return true
194 }
195 }
196 return false
197}
198
199func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
200 if expr == nil {
201 return false
202 }
203 return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
204}
205
206func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
207 sel, ok := expr.(*ast.SelectorExpr)
208 if !ok {
209 return false
210 }
211 return f.exprUsesRole(sel.X, roles...)
212}
213
214func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
215 for _, role := range roles {
216 if f.typeHasRole(t, role) {
217 return true
218 }
219 }
220 return false
221}
222
223func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
224 if t == nil {
225 return false
226 }
227 switch tt := t.(type) {
228 case *types.Pointer:
229 return f.typeHasRole(tt.Elem(), role)
230 case *types.Slice:
231 return f.typeHasRole(tt.Elem(), role)
232 case *types.Array:
233 return f.typeHasRole(tt.Elem(), role)
234 case *types.Map:
235 return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
236 case *types.Signature:
237 return f.typeHasRole(tt.Results(), role)
238 case *types.Tuple:
239 for i := 0; i < tt.Len(); i++ {
240 if f.typeHasRole(tt.At(i).Type(), role) {
241 return true
242 }
243 }
244 return false
245 case *types.Named:
246 obj := tt.Obj()
247 if obj == nil || obj.Pkg() == nil {
248 return false
249 }
250 match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
251 if ok && match.Role == role {
252 return true
253 }
254 return f.typeHasRole(tt.Underlying(), role)
255 default:
256 return false
257 }
258}
259
260func exprFromNode(n ast.Node) ast.Expr {
261 expr, _ := n.(ast.Expr)
262 return expr
263}
264
265func signalKinds(signals []businessLogicSignal) []string {
266 out := make([]string, 0, len(signals))
267 for _, signal := range signals {
268 out = append(out, signal.kind)
269 }
270 return out
271}
272
273func containsWord(name, word string) bool {
274 if word == "" || len(word) > len(name) {
275 return false
276 }
277 for i := 0; i <= len(name)-len(word); i++ {
278 if !strings.EqualFold(name[i:i+len(word)], word) {
279 continue
280 }
281 beforeOK := i == 0 || isBoundaryRune(rune(name[i-1]), rune(name[i]))
282 after := i + len(word)
283 afterOK := after == len(name) || isBoundaryRune(rune(name[after-1]), rune(name[after]))
284 if beforeOK && afterOK {
285 return true
286 }
287 }
288 return false
289}
290
291func isBoundaryRune(prev, next rune) bool {
292 return prev == '_' || next == '_' || (prev >= 'a' && prev <= 'z' && next >= 'A' && next <= 'Z')
293}