v0.3.0 a7c1682

business_logic.go RawHistoryFolder

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}

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