fix(analyzer): report business logic component role
- commit
- 9b450a9e6c616cf07227086f1e6230f1b85e73ae
- author
- brice · · 2026-05-30 10:33 UTC
- parent
- b9fa58d
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 116 | 116 | if err != nil { | |
| 117 | 117 | return "" | |
| 118 | 118 | } | |
| 119 | - | for _, line := range strings.Split(string(data), "\n") { | |
| 119 | + | for line := range strings.SplitSeq(string(data), "\n") { | |
| 120 | 120 | 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) | |
| 123 | 123 | } | |
| 124 | 124 | } | |
| 125 | 125 | return "" | |
| @@ -282,8 +282,10 @@ func (r runner) isExcluded(rule, filePath string) bool { | |||
| 282 | 282 | } | |
| 283 | 283 | ||
| 284 | 284 | 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 | + | } | |
| 287 | 289 | } | |
| 288 | 290 | if r.modulePath != "" && importPath == r.modulePath { | |
| 289 | 291 | return "" | |
| @@ -299,8 +301,8 @@ func (r runner) filePath(file *ast.File) string { | |||
| 299 | 301 | } | |
| 300 | 302 | if r.cfg.Root != "" { | |
| 301 | 303 | 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 | |
| 304 | 306 | } | |
| 305 | 307 | } | |
| 306 | 308 | 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 pos | 308 | return pos | ||
M analyzer/analyzer_test.go +2 -0
| @@ -24,6 +24,7 @@ func TestAnalyzer(t *testing.T) { | |||
| 24 | 24 | name: "heuristics", | |
| 25 | 25 | patterns: []string{ | |
| 26 | 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 | 54 | "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}}, | |
| 54 | 55 | "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}}, | |
| 55 | 56 | "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}}, | |
| 57 | + | "cmd": {Role: config.RoleEntrypoint, Paths: []string{"cmd/**"}}, | |
| 56 | 58 | }, | |
| 57 | 59 | Rules: config.DefaultRuleSeverities(), | |
| 58 | 60 | 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) | |||
| 45 | 45 | } | |
| 46 | 46 | strong, weak := facts.classify() | |
| 47 | 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 | 50 | return false | |
| 51 | 51 | } | |
| @@ -136,6 +136,15 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string { | |||
| 136 | 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 | 148 | func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) { | |
| 140 | 149 | strong := []businessLogicSignal{} | |
| 141 | 150 | weak := []businessLogicSignal{} | |
| @@ -236,8 +245,8 @@ func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool | |||
| 236 | 245 | case *types.Signature: | |
| 237 | 246 | return f.typeHasRole(tt.Results(), role) | |
| 238 | 247 | 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) { | |
| 241 | 250 | return true | |
| 242 | 251 | } | |
| 243 | 252 | } | |
| @@ -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 false | 50 | 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 true | 250 | 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 | |||
| 2 | 2 | ||
| 3 | 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 | 6 | if order.Name == "" { | |
| 7 | 7 | return domain.PolicyError{} | |
| 8 | 8 | } | |
| @@ -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 | } | ||