v0.1.0 79118a8

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 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}

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