detached 9b450a9

fix(analyzer): report business logic component role

commit
9b450a9e6c616cf07227086f1e6230f1b85e73ae
author
brice · · 2026-05-30 10:33 UTC
parent
b9fa58d
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
5 files · +41 -11
M analyzer/analyzer.go +9 -7
@@ -116,10 +116,10 @@ func discoverModulePath(root string) string {
116116 if err != nil {
117117 return ""
118118 }
119- for _, line := range strings.Split(string(data), "\n") {
119+ for line := range strings.SplitSeq(string(data), "\n") {
120120 line = strings.TrimSpace(line)
121- if strings.HasPrefix(line, "module ") {
122- return strings.TrimSpace(strings.TrimPrefix(line, "module "))
121+ if modulePath, ok := strings.CutPrefix(line, "module "); ok {
122+ return strings.TrimSpace(modulePath)
123123 }
124124 }
125125 return ""
@@ -282,8 +282,10 @@ func (r runner) isExcluded(rule, filePath string) bool {
282282 }
283283
284284 func (r runner) relImportPath(importPath string) string {
285- if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {
286- return strings.TrimPrefix(importPath, r.modulePath+"/")
285+ if r.modulePath != "" {
286+ if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
287+ return rel
288+ }
287289 }
288290 if r.modulePath != "" && importPath == r.modulePath {
289291 return ""
@@ -299,8 +301,8 @@ func (r runner) filePath(file *ast.File) string {
299301 }
300302 if r.cfg.Root != "" {
301303 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
302- if strings.HasPrefix(pos, root+"/") {
303- return strings.TrimPrefix(pos, root+"/")
304+ if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
305+ return rel
304306 }
305307 }
306308 return pos
@@ -116,10 +116,10 @@ func discoverModulePath(root string) string {
116 if err != nil {116 if err != nil {
117 return ""117 return ""
118 }118 }
119- for _, line := range strings.Split(string(data), "\n") {119+ for line := range strings.SplitSeq(string(data), "\n") {
120 line = strings.TrimSpace(line)120 line = strings.TrimSpace(line)
121- if strings.HasPrefix(line, "module ") {121+ if modulePath, ok := strings.CutPrefix(line, "module "); ok {
122- return strings.TrimSpace(strings.TrimPrefix(line, "module "))122+ return strings.TrimSpace(modulePath)
123 }123 }
124 }124 }
125 return ""125 return ""
@@ -282,8 +282,10 @@ func (r runner) isExcluded(rule, filePath string) bool {
282 }282 }
283 283
284 func (r runner) relImportPath(importPath string) string {284 func (r runner) relImportPath(importPath string) string {
285- if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {285+ if r.modulePath != "" {
286- return strings.TrimPrefix(importPath, r.modulePath+"/")286+ if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
287+ return rel
288+ }
287 }289 }
288 if r.modulePath != "" && importPath == r.modulePath {290 if r.modulePath != "" && importPath == r.modulePath {
289 return ""291 return ""
@@ -299,8 +301,8 @@ func (r runner) filePath(file *ast.File) string {
299 }301 }
300 if r.cfg.Root != "" {302 if r.cfg.Root != "" {
301 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")303 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
302- if strings.HasPrefix(pos, root+"/") {304+ if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
303- return strings.TrimPrefix(pos, root+"/")305+ return rel
304 }306 }
305 }307 }
306 return pos308 return pos
M analyzer/analyzer_test.go +2 -0
@@ -24,6 +24,7 @@ func TestAnalyzer(t *testing.T) {
2424 name: "heuristics",
2525 patterns: []string{
2626 "example.com/project/internal/infrastructure/http",
27+ "example.com/project/cmd/app",
2728 },
2829 },
2930 {
@@ -53,6 +54,7 @@ func testConfig() *config.Config {
5354 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
5455 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
5556 "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},
57+ "cmd": {Role: config.RoleEntrypoint, Paths: []string{"cmd/**"}},
5658 },
5759 Rules: config.DefaultRuleSeverities(),
5860 Heuristics: config.Heuristics{
@@ -24,6 +24,7 @@ func TestAnalyzer(t *testing.T) {
24 name: "heuristics",24 name: "heuristics",
25 patterns: []string{25 patterns: []string{
26 "example.com/project/internal/infrastructure/http",26 "example.com/project/internal/infrastructure/http",
27+ "example.com/project/cmd/app",
27 },28 },
28 },29 },
29 {30 {
@@ -53,6 +54,7 @@ func testConfig() *config.Config {
53 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},54 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
54 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},55 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
55 "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},56 "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},
57+ "cmd": {Role: config.RoleEntrypoint, Paths: []string{"cmd/**"}},
56 },58 },
57 Rules: config.DefaultRuleSeverities(),59 Rules: config.DefaultRuleSeverities(),
58 Heuristics: config.Heuristics{60 Heuristics: config.Heuristics{
M analyzer/business_logic.go +12 -3
@@ -45,7 +45,7 @@ func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string)
4545 }
4646 strong, weak := facts.classify()
4747 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))
48+ return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "%s function %s has business-logic evidence: strong=%v weak=%v", facts.componentRole(), fn.Name.Name, signalKinds(strong), signalKinds(weak))
4949 }
5050 return false
5151 }
@@ -136,6 +136,15 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string {
136136 return fmt.Sprintf("%p", expr)
137137 }
138138
139+func (f *businessFunctionFacts) componentRole() config.Role {
140+ pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path())
141+ current, ok := f.runner.cfg.ComponentForPath(pkgRel)
142+ if !ok {
143+ return config.RoleAdapter
144+ }
145+ return current.Role
146+}
147+
139148 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140149 strong := []businessLogicSignal{}
141150 weak := []businessLogicSignal{}
@@ -236,8 +245,8 @@ func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool
236245 case *types.Signature:
237246 return f.typeHasRole(tt.Results(), role)
238247 case *types.Tuple:
239- for i := 0; i < tt.Len(); i++ {
240- if f.typeHasRole(tt.At(i).Type(), role) {
248+ for v := range tt.Variables() {
249+ if f.typeHasRole(v.Type(), role) {
241250 return true
242251 }
243252 }
@@ -45,7 +45,7 @@ func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string)
45 }45 }
46 strong, weak := facts.classify()46 strong, weak := facts.classify()
47 if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) {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))48+ return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "%s function %s has business-logic evidence: strong=%v weak=%v", facts.componentRole(), fn.Name.Name, signalKinds(strong), signalKinds(weak))
49 }49 }
50 return false50 return false
51 }51 }
@@ -136,6 +136,15 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string {
136 return fmt.Sprintf("%p", expr)136 return fmt.Sprintf("%p", expr)
137 }137 }
138 138
139+func (f *businessFunctionFacts) componentRole() config.Role {
140+ pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path())
141+ current, ok := f.runner.cfg.ComponentForPath(pkgRel)
142+ if !ok {
143+ return config.RoleAdapter
144+ }
145+ return current.Role
146+}
147+
139 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {148 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140 strong := []businessLogicSignal{}149 strong := []businessLogicSignal{}
141 weak := []businessLogicSignal{}150 weak := []businessLogicSignal{}
@@ -236,8 +245,8 @@ func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool
236 case *types.Signature:245 case *types.Signature:
237 return f.typeHasRole(tt.Results(), role)246 return f.typeHasRole(tt.Results(), role)
238 case *types.Tuple:247 case *types.Tuple:
239- for i := 0; i < tt.Len(); i++ {248+ for v := range tt.Variables() {
240- if f.typeHasRole(tt.At(i).Type(), role) {249+ if f.typeHasRole(v.Type(), role) {
241 return true250 return true
242 }251 }
243 }252 }
A analyzer/testdata/src/example.com/project/cmd/app/main.go +17 -0
@@ -0,0 +1,17 @@
1+package main
2+
3+import "example.com/project/internal/domain"
4+
5+func ResolveStartupMode(user *domain.User) string { // want "suspicious-business-logic-in-adapter: entrypoint function ResolveStartupMode"
6+ user.Name = "seen"
7+ if user.Name == "admin" {
8+ return "admin"
9+ }
10+ if user.Name == "guest" {
11+ return "guest"
12+ }
13+ if user.Name == "" {
14+ return "empty"
15+ }
16+ return "normal"
17+}
@@ -0,0 +1,17 @@
1+package main
2+
3+import "example.com/project/internal/domain"
4+
5+func ResolveStartupMode(user *domain.User) string { // want "suspicious-business-logic-in-adapter: entrypoint function ResolveStartupMode"
6+ user.Name = "seen"
7+ if user.Name == "admin" {
8+ return "admin"
9+ }
10+ if user.Name == "guest" {
11+ return "guest"
12+ }
13+ if user.Name == "" {
14+ return "empty"
15+ }
16+ return "normal"
17+}
M analyzer/testdata/src/example.com/project/internal/infrastructure/http/logic.go +1 -1
@@ -2,7 +2,7 @@ package http
22
33 import "example.com/project/internal/domain"
44
5-func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"
5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter: adapter function ValidateOrder"
66 if order.Name == "" {
77 return domain.PolicyError{}
88 }
@@ -2,7 +2,7 @@ package http
2 2
3 import "example.com/project/internal/domain"3 import "example.com/project/internal/domain"
4 4
5-func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter: adapter function ValidateOrder"
6 if order.Name == "" {6 if order.Name == "" {
7 return domain.PolicyError{}7 return domain.PolicyError{}
8 }8 }

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