feat(analyzer): add business logic confidence controls
- commit
- dee20a21f2323b90ec8b28a855dad050ee808cf1
- author
- brice · · 2026-05-30 11:10 UTC
- parent
- 9ad764f
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
13 files · +290 -11
M README.md +4 -0
| @@ -81,6 +81,8 @@ heuristics: | |||
| 81 | 81 | businessLogicMinWeakSignals: 2 | |
| 82 | 82 | businessLogicMaxFunctionNodes: 2000 | |
| 83 | 83 | businessLogicMaxDiagnosticsPerPackage: 10 | |
| 84 | + | businessLogicMode: audit # audit|ci | |
| 85 | + | businessLogicMinConfidence: medium # low|medium|high | |
| 84 | 86 | excludeTestFiles: true | |
| 85 | 87 | ||
| 86 | 88 | mocking: | |
| @@ -95,6 +97,8 @@ mocking: | |||
| 95 | 97 | ||
| 96 | 98 | A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml). | |
| 97 | 99 | ||
| 100 | + | Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings. | |
| 101 | + | ||
| 98 | 102 | ## Standalone CLI | |
| 99 | 103 | ||
| 100 | 104 | Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix: | |
| @@ -81,6 +81,8 @@ heuristics: | |||||
| 81 | businessLogicMinWeakSignals: 2 | 81 | businessLogicMinWeakSignals: 2 | ||
| 82 | businessLogicMaxFunctionNodes: 2000 | 82 | businessLogicMaxFunctionNodes: 2000 | ||
| 83 | businessLogicMaxDiagnosticsPerPackage: 10 | 83 | businessLogicMaxDiagnosticsPerPackage: 10 | ||
| 84 | + | businessLogicMode: audit # audit|ci | |||
| 85 | + | businessLogicMinConfidence: medium # low|medium|high | |||
| 84 | excludeTestFiles: true | 86 | excludeTestFiles: true | ||
| 85 | 87 | ||||
| 86 | mocking: | 88 | mocking: | ||
| @@ -95,6 +97,8 @@ mocking: | |||||
| 95 | 97 | ||||
| 96 | A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml). | 98 | A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml). | ||
| 97 | 99 | ||||
| 100 | + | Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings. | |||
| 101 | + | ||||
| 98 | ## Standalone CLI | 102 | ## Standalone CLI | ||
| 99 | 103 | ||||
| 100 | Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix: | 104 | Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix: | ||
M SKILL.md +10 -0
| @@ -45,6 +45,16 @@ rules: | |||
| 45 | 45 | prefer-generated-mocks: warn | |
| 46 | 46 | ``` | |
| 47 | 47 | ||
| 48 | + | Business-logic mode: | |
| 49 | + | ||
| 50 | + | ```yaml | |
| 51 | + | heuristics: | |
| 52 | + | businessLogicMode: audit # audit|ci | |
| 53 | + | businessLogicMinConfidence: medium # low|medium|high | |
| 54 | + | ``` | |
| 55 | + | ||
| 56 | + | Use `audit` for refactor discovery. Use `ci` when heuristic findings must stay high-confidence. Prefer tuning confidence before adding broad `excludePaths`. | |
| 57 | + | ||
| 48 | 58 | Mock config: | |
| 49 | 59 | ||
| 50 | 60 | ```yaml | |
| @@ -45,6 +45,16 @@ rules: | |||||
| 45 | prefer-generated-mocks: warn | 45 | prefer-generated-mocks: warn | ||
| 46 | ``` | 46 | ``` | ||
| 47 | 47 | ||||
| 48 | + | Business-logic mode: | |||
| 49 | + | ||||
| 50 | + | ```yaml | |||
| 51 | + | heuristics: | |||
| 52 | + | businessLogicMode: audit # audit|ci | |||
| 53 | + | businessLogicMinConfidence: medium # low|medium|high | |||
| 54 | + | ``` | |||
| 55 | + | ||||
| 56 | + | Use `audit` for refactor discovery. Use `ci` when heuristic findings must stay high-confidence. Prefer tuning confidence before adding broad `excludePaths`. | |||
| 57 | + | ||||
| 48 | Mock config: | 58 | Mock config: | ||
| 49 | 59 | ||||
| 50 | ```yaml | 60 | ```yaml | ||
M analyzer/analyzer_test.go +2 -0
| @@ -63,6 +63,8 @@ func testConfig() *config.Config { | |||
| 63 | 63 | BusinessLogicMinWeakSignals: testIntPtr(2), | |
| 64 | 64 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | |
| 65 | 65 | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10), | |
| 66 | + | BusinessLogicMode: "audit", | |
| 67 | + | BusinessLogicMinConfidence: "medium", | |
| 66 | 68 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | |
| 67 | 69 | }, | |
| 68 | 70 | ExternalTypes: config.ExternalTypes{ | |
| @@ -63,6 +63,8 @@ func testConfig() *config.Config { | |||||
| 63 | BusinessLogicMinWeakSignals: testIntPtr(2), | 63 | BusinessLogicMinWeakSignals: testIntPtr(2), | ||
| 64 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | 64 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | ||
| 65 | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10), | 65 | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10), | ||
| 66 | + | BusinessLogicMode: "audit", | |||
| 67 | + | BusinessLogicMinConfidence: "medium", | |||
| 66 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | 68 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | ||
| 67 | }, | 69 | }, | ||
| 68 | ExternalTypes: config.ExternalTypes{ | 70 | ExternalTypes: config.ExternalTypes{ | ||
M analyzer/business_logic.go +96 -7
| @@ -10,6 +10,15 @@ import ( | |||
| 10 | 10 | "github.com/bnema/hexcheck/config" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 13 | + | type businessLogicConfidence string | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | businessLogicConfidenceNone businessLogicConfidence = "none" | |
| 17 | + | businessLogicConfidenceLow businessLogicConfidence = "low" | |
| 18 | + | businessLogicConfidenceMedium businessLogicConfidence = "medium" | |
| 19 | + | businessLogicConfidenceHigh businessLogicConfidence = "high" | |
| 20 | + | ) | |
| 21 | + | ||
| 13 | 22 | type businessLogicSignal struct { | |
| 14 | 23 | kind string | |
| 15 | 24 | strong bool | |
| @@ -31,26 +40,35 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 31 | 40 | if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | |
| 32 | 41 | return | |
| 33 | 42 | } | |
| 34 | - | if r.isSuspiciousBusinessFunction(fn, filePath) { | |
| 43 | + | if r.isSuspiciousBusinessFunction(fn, file, filePath) { | |
| 35 | 44 | *packageDiagnostics++ | |
| 36 | 45 | } | |
| 37 | 46 | } | |
| 38 | 47 | } | |
| 39 | 48 | ||
| 40 | - | func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string) bool { | |
| 41 | - | facts := r.collectBusinessFunctionFacts(fn) | |
| 49 | + | func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string) bool { | |
| 50 | + | facts := r.collectBusinessFunctionFacts(fn, file) | |
| 42 | 51 | if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | |
| 43 | 52 | return false | |
| 44 | 53 | } | |
| 45 | 54 | strong, weak := facts.classify() | |
| 46 | - | if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) { | |
| 47 | - | 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)) | |
| 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)) | |
| 48 | 61 | } | |
| 49 | 62 | return false | |
| 50 | 63 | } | |
| 51 | 64 | ||
| 52 | - | func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl) businessFunctionFacts { | |
| 53 | - | facts := businessFunctionFacts{runner: r, fn: fn} | |
| 65 | + | func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, file ...*ast.File) businessFunctionFacts { | |
| 66 | + | facts := businessFunctionFacts{runner: r, fn: fn, imports: map[string]bool{}} | |
| 67 | + | if len(file) > 0 && file[0] != nil { | |
| 68 | + | for _, imp := range file[0].Imports { | |
| 69 | + | facts.imports[strings.Trim(imp.Path.Value, "\"")] = true | |
| 70 | + | } | |
| 71 | + | } | |
| 54 | 72 | ast.Inspect(fn.Body, facts.inspect) | |
| 55 | 73 | return facts | |
| 56 | 74 | } | |
| @@ -69,6 +87,8 @@ type businessFunctionFacts struct { | |||
| 69 | 87 | domainErrorReturn bool | |
| 70 | 88 | policyConstant bool | |
| 71 | 89 | usecaseCalls map[string]bool | |
| 90 | + | imports map[string]bool | |
| 91 | + | usesTechnicalPackage bool | |
| 72 | 92 | } | |
| 73 | 93 | ||
| 74 | 94 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |
| @@ -110,6 +130,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||
| 110 | 130 | } | |
| 111 | 131 | ||
| 112 | 132 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | |
| 133 | + | f.inspectTechnicalCall(call) | |
| 113 | 134 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | |
| 114 | 135 | if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) { | |
| 115 | 136 | f.domainConstructorCall = true | |
| @@ -132,6 +153,29 @@ func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | |||
| 132 | 153 | } | |
| 133 | 154 | } | |
| 134 | 155 | ||
| 156 | + | func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) { | |
| 157 | + | sel, ok := call.Fun.(*ast.SelectorExpr) | |
| 158 | + | if !ok { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | ident, ok := sel.X.(*ast.Ident) | |
| 162 | + | if !ok { | |
| 163 | + | return | |
| 164 | + | } | |
| 165 | + | if _, ok := map[string]bool{"os": true, "filepath": true, "strconv": true, "bufio": true}[ident.Name]; ok && f.imports[technicalImportPath(ident.Name)] { | |
| 166 | + | f.usesTechnicalPackage = true | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | ||
| 170 | + | func technicalImportPath(name string) string { | |
| 171 | + | switch name { | |
| 172 | + | case "filepath": | |
| 173 | + | return "path/filepath" | |
| 174 | + | default: | |
| 175 | + | return name | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | ||
| 135 | 179 | func collaboratorKey(info *types.Info, expr ast.Expr) string { | |
| 136 | 180 | if ident, ok := expr.(*ast.Ident); ok { | |
| 137 | 181 | if obj := info.ObjectOf(ident); obj != nil { | |
| @@ -141,6 +185,51 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string { | |||
| 141 | 185 | return fmt.Sprintf("%p", expr) | |
| 142 | 186 | } | |
| 143 | 187 | ||
| 188 | + | func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence { | |
| 189 | + | if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals { | |
| 190 | + | return businessLogicConfidenceHigh | |
| 191 | + | } | |
| 192 | + | if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals { | |
| 193 | + | return businessLogicConfidenceMedium | |
| 194 | + | } | |
| 195 | + | if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals { | |
| 196 | + | return businessLogicConfidenceLow | |
| 197 | + | } | |
| 198 | + | return businessLogicConfidenceNone | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | func effectiveBusinessLogicMinConfidence(cfg *config.Config) string { | |
| 202 | + | min := cfg.Heuristics.BusinessLogicMinConfidence | |
| 203 | + | if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) { | |
| 204 | + | return string(businessLogicConfidenceHigh) | |
| 205 | + | } | |
| 206 | + | return min | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | func confidenceMeets(got businessLogicConfidence, min string) bool { | |
| 210 | + | levels := map[businessLogicConfidence]int{ | |
| 211 | + | businessLogicConfidenceNone: 0, | |
| 212 | + | businessLogicConfidenceLow: 1, | |
| 213 | + | businessLogicConfidenceMedium: 2, | |
| 214 | + | businessLogicConfidenceHigh: 3, | |
| 215 | + | } | |
| 216 | + | want, ok := levels[businessLogicConfidence(min)] | |
| 217 | + | if !ok { | |
| 218 | + | want = levels[businessLogicConfidenceLow] | |
| 219 | + | } | |
| 220 | + | return levels[got] >= want | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool { | |
| 224 | + | if !containsWord(f.fn.Name.Name, "Detect") { | |
| 225 | + | return false | |
| 226 | + | } | |
| 227 | + | if !f.usesTechnicalPackage { | |
| 228 | + | return false | |
| 229 | + | } | |
| 230 | + | return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0 | |
| 231 | + | } | |
| 232 | + | ||
| 144 | 233 | func (f *businessFunctionFacts) componentRole() config.Role { | |
| 145 | 234 | pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path()) | |
| 146 | 235 | current, ok := f.runner.cfg.ComponentForPath(pkgRel) | |
| @@ -10,6 +10,15 @@ import ( | |||||
| 10 | "github.com/bnema/hexcheck/config" | 10 | "github.com/bnema/hexcheck/config" | ||
| 11 | ) | 11 | ) | ||
| 12 | 12 | ||||
| 13 | + | type businessLogicConfidence string | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | businessLogicConfidenceNone businessLogicConfidence = "none" | |||
| 17 | + | businessLogicConfidenceLow businessLogicConfidence = "low" | |||
| 18 | + | businessLogicConfidenceMedium businessLogicConfidence = "medium" | |||
| 19 | + | businessLogicConfidenceHigh businessLogicConfidence = "high" | |||
| 20 | + | ) | |||
| 21 | + | ||||
| 13 | type businessLogicSignal struct { | 22 | type businessLogicSignal struct { | ||
| 14 | kind string | 23 | kind string | ||
| 15 | strong bool | 24 | strong bool | ||
| @@ -31,26 +40,35 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 31 | if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | 40 | if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | ||
| 32 | return | 41 | return | ||
| 33 | } | 42 | } | ||
| 34 | - | if r.isSuspiciousBusinessFunction(fn, filePath) { | 43 | + | if r.isSuspiciousBusinessFunction(fn, file, filePath) { |
| 35 | *packageDiagnostics++ | 44 | *packageDiagnostics++ | ||
| 36 | } | 45 | } | ||
| 37 | } | 46 | } | ||
| 38 | } | 47 | } | ||
| 39 | 48 | ||||
| 40 | - | func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string) bool { | 49 | + | func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string) bool { |
| 41 | - | facts := r.collectBusinessFunctionFacts(fn) | 50 | + | facts := r.collectBusinessFunctionFacts(fn, file) |
| 42 | if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | 51 | if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | ||
| 43 | return false | 52 | return false | ||
| 44 | } | 53 | } | ||
| 45 | strong, weak := facts.classify() | 54 | strong, weak := facts.classify() | ||
| 46 | - | if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) { | 55 | + | if facts.isTechnicalDetectionOnly() { |
| 47 | - | 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)) | 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)) | |||
| 48 | } | 61 | } | ||
| 49 | return false | 62 | return false | ||
| 50 | } | 63 | } | ||
| 51 | 64 | ||||
| 52 | - | func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl) businessFunctionFacts { | 65 | + | func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, file ...*ast.File) businessFunctionFacts { |
| 53 | - | facts := businessFunctionFacts{runner: r, fn: fn} | 66 | + | facts := businessFunctionFacts{runner: r, fn: fn, imports: map[string]bool{}} |
| 67 | + | if len(file) > 0 && file[0] != nil { | |||
| 68 | + | for _, imp := range file[0].Imports { | |||
| 69 | + | facts.imports[strings.Trim(imp.Path.Value, "\"")] = true | |||
| 70 | + | } | |||
| 71 | + | } | |||
| 54 | ast.Inspect(fn.Body, facts.inspect) | 72 | ast.Inspect(fn.Body, facts.inspect) | ||
| 55 | return facts | 73 | return facts | ||
| 56 | } | 74 | } | ||
| @@ -69,6 +87,8 @@ type businessFunctionFacts struct { | |||||
| 69 | domainErrorReturn bool | 87 | domainErrorReturn bool | ||
| 70 | policyConstant bool | 88 | policyConstant bool | ||
| 71 | usecaseCalls map[string]bool | 89 | usecaseCalls map[string]bool | ||
| 90 | + | imports map[string]bool | |||
| 91 | + | usesTechnicalPackage bool | |||
| 72 | } | 92 | } | ||
| 73 | 93 | ||||
| 74 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | 94 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | ||
| @@ -110,6 +130,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||||
| 110 | } | 130 | } | ||
| 111 | 131 | ||||
| 112 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | 132 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | ||
| 133 | + | f.inspectTechnicalCall(call) | |||
| 113 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | 134 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | ||
| 114 | if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) { | 135 | if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) { | ||
| 115 | f.domainConstructorCall = true | 136 | f.domainConstructorCall = true | ||
| @@ -132,6 +153,29 @@ func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | |||||
| 132 | } | 153 | } | ||
| 133 | } | 154 | } | ||
| 134 | 155 | ||||
| 156 | + | func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) { | |||
| 157 | + | sel, ok := call.Fun.(*ast.SelectorExpr) | |||
| 158 | + | if !ok { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | ident, ok := sel.X.(*ast.Ident) | |||
| 162 | + | if !ok { | |||
| 163 | + | return | |||
| 164 | + | } | |||
| 165 | + | if _, ok := map[string]bool{"os": true, "filepath": true, "strconv": true, "bufio": true}[ident.Name]; ok && f.imports[technicalImportPath(ident.Name)] { | |||
| 166 | + | f.usesTechnicalPackage = true | |||
| 167 | + | } | |||
| 168 | + | } | |||
| 169 | + | ||||
| 170 | + | func technicalImportPath(name string) string { | |||
| 171 | + | switch name { | |||
| 172 | + | case "filepath": | |||
| 173 | + | return "path/filepath" | |||
| 174 | + | default: | |||
| 175 | + | return name | |||
| 176 | + | } | |||
| 177 | + | } | |||
| 178 | + | ||||
| 135 | func collaboratorKey(info *types.Info, expr ast.Expr) string { | 179 | func collaboratorKey(info *types.Info, expr ast.Expr) string { | ||
| 136 | if ident, ok := expr.(*ast.Ident); ok { | 180 | if ident, ok := expr.(*ast.Ident); ok { | ||
| 137 | if obj := info.ObjectOf(ident); obj != nil { | 181 | if obj := info.ObjectOf(ident); obj != nil { | ||
| @@ -141,6 +185,51 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string { | |||||
| 141 | return fmt.Sprintf("%p", expr) | 185 | return fmt.Sprintf("%p", expr) | ||
| 142 | } | 186 | } | ||
| 143 | 187 | ||||
| 188 | + | func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence { | |||
| 189 | + | if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals { | |||
| 190 | + | return businessLogicConfidenceHigh | |||
| 191 | + | } | |||
| 192 | + | if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals { | |||
| 193 | + | return businessLogicConfidenceMedium | |||
| 194 | + | } | |||
| 195 | + | if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals { | |||
| 196 | + | return businessLogicConfidenceLow | |||
| 197 | + | } | |||
| 198 | + | return businessLogicConfidenceNone | |||
| 199 | + | } | |||
| 200 | + | ||||
| 201 | + | func effectiveBusinessLogicMinConfidence(cfg *config.Config) string { | |||
| 202 | + | min := cfg.Heuristics.BusinessLogicMinConfidence | |||
| 203 | + | if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) { | |||
| 204 | + | return string(businessLogicConfidenceHigh) | |||
| 205 | + | } | |||
| 206 | + | return min | |||
| 207 | + | } | |||
| 208 | + | ||||
| 209 | + | func confidenceMeets(got businessLogicConfidence, min string) bool { | |||
| 210 | + | levels := map[businessLogicConfidence]int{ | |||
| 211 | + | businessLogicConfidenceNone: 0, | |||
| 212 | + | businessLogicConfidenceLow: 1, | |||
| 213 | + | businessLogicConfidenceMedium: 2, | |||
| 214 | + | businessLogicConfidenceHigh: 3, | |||
| 215 | + | } | |||
| 216 | + | want, ok := levels[businessLogicConfidence(min)] | |||
| 217 | + | if !ok { | |||
| 218 | + | want = levels[businessLogicConfidenceLow] | |||
| 219 | + | } | |||
| 220 | + | return levels[got] >= want | |||
| 221 | + | } | |||
| 222 | + | ||||
| 223 | + | func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool { | |||
| 224 | + | if !containsWord(f.fn.Name.Name, "Detect") { | |||
| 225 | + | return false | |||
| 226 | + | } | |||
| 227 | + | if !f.usesTechnicalPackage { | |||
| 228 | + | return false | |||
| 229 | + | } | |||
| 230 | + | return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0 | |||
| 231 | + | } | |||
| 232 | + | ||||
| 144 | func (f *businessFunctionFacts) componentRole() config.Role { | 233 | func (f *businessFunctionFacts) componentRole() config.Role { | ||
| 145 | pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path()) | 234 | pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path()) | ||
| 146 | current, ok := f.runner.cfg.ComponentForPath(pkgRel) | 235 | current, ok := f.runner.cfg.ComponentForPath(pkgRel) | ||
A analyzer/business_logic_confidence_test.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package analyzer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/hexcheck/config" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestEffectiveBusinessLogicMinConfidence(t *testing.T) { | |
| 10 | + | tests := []struct { | |
| 11 | + | name string | |
| 12 | + | mode string | |
| 13 | + | min string | |
| 14 | + | want string | |
| 15 | + | }{ | |
| 16 | + | {name: "audit uses configured minimum", mode: "audit", min: "low", want: "low"}, | |
| 17 | + | {name: "ci promotes low to high", mode: "ci", min: "low", want: "high"}, | |
| 18 | + | {name: "ci promotes medium to high", mode: "ci", min: "medium", want: "high"}, | |
| 19 | + | {name: "ci keeps high", mode: "ci", min: "high", want: "high"}, | |
| 20 | + | } | |
| 21 | + | for _, tt := range tests { | |
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |
| 23 | + | cfg := &config.Config{Heuristics: config.Heuristics{BusinessLogicMode: tt.mode, BusinessLogicMinConfidence: tt.min}} | |
| 24 | + | if got := effectiveBusinessLogicMinConfidence(cfg); got != tt.want { | |
| 25 | + | t.Fatalf("effectiveBusinessLogicMinConfidence() = %q, want %q", got, tt.want) | |
| 26 | + | } | |
| 27 | + | }) | |
| 28 | + | } | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package analyzer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/hexcheck/config" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func TestEffectiveBusinessLogicMinConfidence(t *testing.T) { | |||
| 10 | + | tests := []struct { | |||
| 11 | + | name string | |||
| 12 | + | mode string | |||
| 13 | + | min string | |||
| 14 | + | want string | |||
| 15 | + | }{ | |||
| 16 | + | {name: "audit uses configured minimum", mode: "audit", min: "low", want: "low"}, | |||
| 17 | + | {name: "ci promotes low to high", mode: "ci", min: "low", want: "high"}, | |||
| 18 | + | {name: "ci promotes medium to high", mode: "ci", min: "medium", want: "high"}, | |||
| 19 | + | {name: "ci keeps high", mode: "ci", min: "high", want: "high"}, | |||
| 20 | + | } | |||
| 21 | + | for _, tt := range tests { | |||
| 22 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 23 | + | cfg := &config.Config{Heuristics: config.Heuristics{BusinessLogicMode: tt.mode, BusinessLogicMinConfidence: tt.min}} | |||
| 24 | + | if got := effectiveBusinessLogicMinConfidence(cfg); got != tt.want { | |||
| 25 | + | t.Fatalf("effectiveBusinessLogicMinConfidence() = %q, want %q", got, tt.want) | |||
| 26 | + | } | |||
| 27 | + | }) | |||
| 28 | + | } | |||
| 29 | + | } | |||
A analyzer/business_logic_golden_test.go +52 -0
| @@ -0,0 +1,52 @@ | |||
| 1 | + | package analyzer | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "path/filepath" | |
| 5 | + | "strings" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "golang.org/x/tools/go/analysis/analysistest" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestBusinessLogicGoldenCases(t *testing.T) { | |
| 12 | + | result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http") | |
| 13 | + | ||
| 14 | + | messagesByFile := map[string][]string{} | |
| 15 | + | for _, diagnostics := range result[0].Diagnostics { | |
| 16 | + | file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename) | |
| 17 | + | messagesByFile[file] = append(messagesByFile[file], diagnostics.Message) | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go") | |
| 21 | + | assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go") | |
| 22 | + | assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go") | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) { | |
| 26 | + | t.Helper() | |
| 27 | + | for file, messages := range messagesByFile { | |
| 28 | + | if !strings.HasSuffix(file, fileSuffix) { | |
| 29 | + | continue | |
| 30 | + | } | |
| 31 | + | for _, message := range messages { | |
| 32 | + | if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | t.Fatalf("expected business-logic diagnostic for %s", fileSuffix) | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) { | |
| 41 | + | t.Helper() | |
| 42 | + | for file, messages := range messagesByFile { | |
| 43 | + | if !strings.HasSuffix(file, fileSuffix) { | |
| 44 | + | continue | |
| 45 | + | } | |
| 46 | + | for _, message := range messages { | |
| 47 | + | if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") { | |
| 48 | + | t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message) | |
| 49 | + | } | |
| 50 | + | } | |
| 51 | + | } | |
| 52 | + | } | |
| @@ -0,0 +1,52 @@ | |||||
| 1 | + | package analyzer | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "strings" | |||
| 6 | + | "testing" | |||
| 7 | + | ||||
| 8 | + | "golang.org/x/tools/go/analysis/analysistest" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func TestBusinessLogicGoldenCases(t *testing.T) { | |||
| 12 | + | result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http") | |||
| 13 | + | ||||
| 14 | + | messagesByFile := map[string][]string{} | |||
| 15 | + | for _, diagnostics := range result[0].Diagnostics { | |||
| 16 | + | file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename) | |||
| 17 | + | messagesByFile[file] = append(messagesByFile[file], diagnostics.Message) | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go") | |||
| 21 | + | assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go") | |||
| 22 | + | assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go") | |||
| 23 | + | } | |||
| 24 | + | ||||
| 25 | + | func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) { | |||
| 26 | + | t.Helper() | |||
| 27 | + | for file, messages := range messagesByFile { | |||
| 28 | + | if !strings.HasSuffix(file, fileSuffix) { | |||
| 29 | + | continue | |||
| 30 | + | } | |||
| 31 | + | for _, message := range messages { | |||
| 32 | + | if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") { | |||
| 33 | + | return | |||
| 34 | + | } | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | t.Fatalf("expected business-logic diagnostic for %s", fileSuffix) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) { | |||
| 41 | + | t.Helper() | |||
| 42 | + | for file, messages := range messagesByFile { | |||
| 43 | + | if !strings.HasSuffix(file, fileSuffix) { | |||
| 44 | + | continue | |||
| 45 | + | } | |||
| 46 | + | for _, message := range messages { | |||
| 47 | + | if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") { | |||
| 48 | + | t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message) | |||
| 49 | + | } | |||
| 50 | + | } | |||
| 51 | + | } | |||
| 52 | + | } | |||
M analyzer/config_clone.go +8 -4
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/hexcheck/config" | |
| 3 | + | import ( | |
| 4 | + | "maps" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/hexcheck/config" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | func cloneConfig(in *config.Config) *config.Config { | |
| 6 | 10 | if in == nil { | |
| @@ -13,9 +17,7 @@ func cloneConfig(in *config.Config) *config.Config { | |||
| 13 | 17 | out.Components[name] = component | |
| 14 | 18 | } | |
| 15 | 19 | out.Rules = make(map[string]config.Severity, len(in.Rules)) | |
| 16 | - | for rule, severity := range in.Rules { | |
| 17 | - | out.Rules[rule] = severity | |
| 18 | - | } | |
| 20 | + | maps.Copy(out.Rules, in.Rules) | |
| 19 | 21 | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | |
| 20 | 22 | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | |
| 21 | 23 | out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...) | |
| @@ -24,6 +26,8 @@ func cloneConfig(in *config.Config) *config.Config { | |||
| 24 | 26 | out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals) | |
| 25 | 27 | out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes) | |
| 26 | 28 | out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage) | |
| 29 | + | out.Heuristics.BusinessLogicMode = in.Heuristics.BusinessLogicMode | |
| 30 | + | out.Heuristics.BusinessLogicMinConfidence = in.Heuristics.BusinessLogicMinConfidence | |
| 27 | 31 | if in.Heuristics.ExcludeTestFiles != nil { | |
| 28 | 32 | value := *in.Heuristics.ExcludeTestFiles | |
| 29 | 33 | out.Heuristics.ExcludeTestFiles = &value | |
| @@ -1,6 +1,10 @@ | |||||
| 1 | package analyzer | 1 | package analyzer | ||
| 2 | 2 | ||||
| 3 | - | import "github.com/bnema/hexcheck/config" | 3 | + | import ( |
| 4 | + | "maps" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/hexcheck/config" | |||
| 7 | + | ) | |||
| 4 | 8 | ||||
| 5 | func cloneConfig(in *config.Config) *config.Config { | 9 | func cloneConfig(in *config.Config) *config.Config { | ||
| 6 | if in == nil { | 10 | if in == nil { | ||
| @@ -13,9 +17,7 @@ func cloneConfig(in *config.Config) *config.Config { | |||||
| 13 | out.Components[name] = component | 17 | out.Components[name] = component | ||
| 14 | } | 18 | } | ||
| 15 | out.Rules = make(map[string]config.Severity, len(in.Rules)) | 19 | out.Rules = make(map[string]config.Severity, len(in.Rules)) | ||
| 16 | - | for rule, severity := range in.Rules { | 20 | + | maps.Copy(out.Rules, in.Rules) |
| 17 | - | out.Rules[rule] = severity | |||
| 18 | - | } | |||
| 19 | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | 21 | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | ||
| 20 | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | 22 | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | ||
| 21 | out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...) | 23 | out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...) | ||
| @@ -24,6 +26,8 @@ func cloneConfig(in *config.Config) *config.Config { | |||||
| 24 | out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals) | 26 | out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals) | ||
| 25 | out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes) | 27 | out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes) | ||
| 26 | out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage) | 28 | out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage) | ||
| 29 | + | out.Heuristics.BusinessLogicMode = in.Heuristics.BusinessLogicMode | |||
| 30 | + | out.Heuristics.BusinessLogicMinConfidence = in.Heuristics.BusinessLogicMinConfidence | |||
| 27 | if in.Heuristics.ExcludeTestFiles != nil { | 31 | if in.Heuristics.ExcludeTestFiles != nil { | ||
| 28 | value := *in.Heuristics.ExcludeTestFiles | 32 | value := *in.Heuristics.ExcludeTestFiles | ||
| 29 | out.Heuristics.ExcludeTestFiles = &value | 33 | out.Heuristics.ExcludeTestFiles = &value | ||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/mixed_detection.go +18 -0
| @@ -0,0 +1,18 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import "os" | |
| 4 | + | ||
| 5 | + | func readRuntimeFlag() string { return os.Getenv("APP_RUNTIME_FLAG") } | |
| 6 | + | ||
| 7 | + | func DetectEligibility(tier string) string { // want "suspicious-business-logic-in-adapter" | |
| 8 | + | if tier == "enterprise" { | |
| 9 | + | return "allowed" | |
| 10 | + | } | |
| 11 | + | if tier == "trial" { | |
| 12 | + | return "limited" | |
| 13 | + | } | |
| 14 | + | if tier == "blocked" { | |
| 15 | + | return "denied" | |
| 16 | + | } | |
| 17 | + | return "unknown" | |
| 18 | + | } | |
| @@ -0,0 +1,18 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import "os" | |||
| 4 | + | ||||
| 5 | + | func readRuntimeFlag() string { return os.Getenv("APP_RUNTIME_FLAG") } | |||
| 6 | + | ||||
| 7 | + | func DetectEligibility(tier string) string { // want "suspicious-business-logic-in-adapter" | |||
| 8 | + | if tier == "enterprise" { | |||
| 9 | + | return "allowed" | |||
| 10 | + | } | |||
| 11 | + | if tier == "trial" { | |||
| 12 | + | return "limited" | |||
| 13 | + | } | |||
| 14 | + | if tier == "blocked" { | |||
| 15 | + | return "denied" | |||
| 16 | + | } | |||
| 17 | + | return "unknown" | |||
| 18 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/probing.go +25 -0
| @@ -0,0 +1,25 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "os" | |
| 5 | + | "strconv" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func DetectRuntimeThreads() int { | |
| 10 | + | value := strings.TrimSpace(os.Getenv("APP_RUNTIME_THREADS")) | |
| 11 | + | if value == "" { | |
| 12 | + | return 0 | |
| 13 | + | } | |
| 14 | + | threads, err := strconv.Atoi(value) | |
| 15 | + | if err != nil { | |
| 16 | + | return 0 | |
| 17 | + | } | |
| 18 | + | if threads < 0 { | |
| 19 | + | return 0 | |
| 20 | + | } | |
| 21 | + | if threads > 256 { | |
| 22 | + | return 256 | |
| 23 | + | } | |
| 24 | + | return threads | |
| 25 | + | } | |
| @@ -0,0 +1,25 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "os" | |||
| 5 | + | "strconv" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func DetectRuntimeThreads() int { | |||
| 10 | + | value := strings.TrimSpace(os.Getenv("APP_RUNTIME_THREADS")) | |||
| 11 | + | if value == "" { | |||
| 12 | + | return 0 | |||
| 13 | + | } | |||
| 14 | + | threads, err := strconv.Atoi(value) | |||
| 15 | + | if err != nil { | |||
| 16 | + | return 0 | |||
| 17 | + | } | |||
| 18 | + | if threads < 0 { | |||
| 19 | + | return 0 | |||
| 20 | + | } | |||
| 21 | + | if threads > 256 { | |||
| 22 | + | return 256 | |||
| 23 | + | } | |||
| 24 | + | return threads | |||
| 25 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/typed_policy.go +24 -0
| @@ -0,0 +1,24 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import "example.com/project/internal/domain" | |
| 4 | + | ||
| 5 | + | func computeUserScore(user *domain.User) int { // want "suspicious-business-logic-in-adapter" | |
| 6 | + | score := 0 | |
| 7 | + | if user.Name == "admin" { | |
| 8 | + | score += 100 | |
| 9 | + | } | |
| 10 | + | if stringsHasPrefix(user.Name, "power") { | |
| 11 | + | score += 50 | |
| 12 | + | } | |
| 13 | + | if user.Name == "guest" { | |
| 14 | + | score -= 10 | |
| 15 | + | } | |
| 16 | + | if score > 100 { | |
| 17 | + | return 100 | |
| 18 | + | } | |
| 19 | + | return score | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | func stringsHasPrefix(value, prefix string) bool { | |
| 23 | + | return len(value) >= len(prefix) && value[:len(prefix)] == prefix | |
| 24 | + | } | |
| @@ -0,0 +1,24 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import "example.com/project/internal/domain" | |||
| 4 | + | ||||
| 5 | + | func computeUserScore(user *domain.User) int { // want "suspicious-business-logic-in-adapter" | |||
| 6 | + | score := 0 | |||
| 7 | + | if user.Name == "admin" { | |||
| 8 | + | score += 100 | |||
| 9 | + | } | |||
| 10 | + | if stringsHasPrefix(user.Name, "power") { | |||
| 11 | + | score += 50 | |||
| 12 | + | } | |||
| 13 | + | if user.Name == "guest" { | |||
| 14 | + | score -= 10 | |||
| 15 | + | } | |||
| 16 | + | if score > 100 { | |||
| 17 | + | return 100 | |||
| 18 | + | } | |||
| 19 | + | return score | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | func stringsHasPrefix(value, prefix string) bool { | |||
| 23 | + | return len(value) >= len(prefix) && value[:len(prefix)] == prefix | |||
| 24 | + | } | |||
M config/config.go +18 -0
| @@ -58,6 +58,8 @@ type Heuristics struct { | |||
| 58 | 58 | BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"` | |
| 59 | 59 | BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"` | |
| 60 | 60 | BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"` | |
| 61 | + | BusinessLogicMode string `yaml:"businessLogicMode"` | |
| 62 | + | BusinessLogicMinConfidence string `yaml:"businessLogicMinConfidence"` | |
| 61 | 63 | BusinessKeywords []string `yaml:"businessKeywords"` | |
| 62 | 64 | ExcludeTestFiles *bool `yaml:"excludeTestFiles"` | |
| 63 | 65 | } | |
| @@ -147,6 +149,12 @@ func (c *Config) applyDefaults() { | |||
| 147 | 149 | defaultExcludeTestFiles := true | |
| 148 | 150 | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | |
| 149 | 151 | } | |
| 152 | + | if c.Heuristics.BusinessLogicMode == "" { | |
| 153 | + | c.Heuristics.BusinessLogicMode = "audit" | |
| 154 | + | } | |
| 155 | + | if c.Heuristics.BusinessLogicMinConfidence == "" { | |
| 156 | + | c.Heuristics.BusinessLogicMinConfidence = "medium" | |
| 157 | + | } | |
| 150 | 158 | if len(c.Heuristics.BusinessKeywords) == 0 { | |
| 151 | 159 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"} | |
| 152 | 160 | } | |
| @@ -220,6 +228,16 @@ func (c *Config) Validate() error { | |||
| 220 | 228 | if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 { | |
| 221 | 229 | return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1") | |
| 222 | 230 | } | |
| 231 | + | switch c.Heuristics.BusinessLogicMode { | |
| 232 | + | case "ci", "audit": | |
| 233 | + | default: | |
| 234 | + | return errors.New("heuristics.businessLogicMode: must be ci or audit") | |
| 235 | + | } | |
| 236 | + | switch c.Heuristics.BusinessLogicMinConfidence { | |
| 237 | + | case "low", "medium", "high": | |
| 238 | + | default: | |
| 239 | + | return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high") | |
| 240 | + | } | |
| 223 | 241 | for rule, severity := range c.Rules { | |
| 224 | 242 | switch severity { | |
| 225 | 243 | case SeverityOff, SeverityWarn, SeverityError: | |
| @@ -58,6 +58,8 @@ type Heuristics struct { | |||||
| 58 | BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"` | 58 | BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"` | ||
| 59 | BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"` | 59 | BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"` | ||
| 60 | BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"` | 60 | BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"` | ||
| 61 | + | BusinessLogicMode string `yaml:"businessLogicMode"` | |||
| 62 | + | BusinessLogicMinConfidence string `yaml:"businessLogicMinConfidence"` | |||
| 61 | BusinessKeywords []string `yaml:"businessKeywords"` | 63 | BusinessKeywords []string `yaml:"businessKeywords"` | ||
| 62 | ExcludeTestFiles *bool `yaml:"excludeTestFiles"` | 64 | ExcludeTestFiles *bool `yaml:"excludeTestFiles"` | ||
| 63 | } | 65 | } | ||
| @@ -147,6 +149,12 @@ func (c *Config) applyDefaults() { | |||||
| 147 | defaultExcludeTestFiles := true | 149 | defaultExcludeTestFiles := true | ||
| 148 | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | 150 | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | ||
| 149 | } | 151 | } | ||
| 152 | + | if c.Heuristics.BusinessLogicMode == "" { | |||
| 153 | + | c.Heuristics.BusinessLogicMode = "audit" | |||
| 154 | + | } | |||
| 155 | + | if c.Heuristics.BusinessLogicMinConfidence == "" { | |||
| 156 | + | c.Heuristics.BusinessLogicMinConfidence = "medium" | |||
| 157 | + | } | |||
| 150 | if len(c.Heuristics.BusinessKeywords) == 0 { | 158 | if len(c.Heuristics.BusinessKeywords) == 0 { | ||
| 151 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"} | 159 | c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"} | ||
| 152 | } | 160 | } | ||
| @@ -220,6 +228,16 @@ func (c *Config) Validate() error { | |||||
| 220 | if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 { | 228 | if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 { | ||
| 221 | return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1") | 229 | return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1") | ||
| 222 | } | 230 | } | ||
| 231 | + | switch c.Heuristics.BusinessLogicMode { | |||
| 232 | + | case "ci", "audit": | |||
| 233 | + | default: | |||
| 234 | + | return errors.New("heuristics.businessLogicMode: must be ci or audit") | |||
| 235 | + | } | |||
| 236 | + | switch c.Heuristics.BusinessLogicMinConfidence { | |||
| 237 | + | case "low", "medium", "high": | |||
| 238 | + | default: | |||
| 239 | + | return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high") | |||
| 240 | + | } | |||
| 223 | for rule, severity := range c.Rules { | 241 | for rule, severity := range c.Rules { | ||
| 224 | switch severity { | 242 | switch severity { | ||
| 225 | case SeverityOff, SeverityWarn, SeverityError: | 243 | case SeverityOff, SeverityWarn, SeverityError: | ||
M config/config_test.go +2 -0
| @@ -146,6 +146,8 @@ func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) { | |||
| 146 | 146 | {name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }}, | |
| 147 | 147 | {name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }}, | |
| 148 | 148 | {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }}, | |
| 149 | + | {name: "invalid business logic mode", mutate: func(c *Config) { c.Heuristics.BusinessLogicMode = "loud" }}, | |
| 150 | + | {name: "invalid business logic min confidence", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinConfidence = "sure" }}, | |
| 149 | 151 | } | |
| 150 | 152 | for _, tt := range tests { | |
| 151 | 153 | t.Run(tt.name, func(t *testing.T) { | |
| @@ -146,6 +146,8 @@ func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) { | |||||
| 146 | {name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }}, | 146 | {name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }}, | ||
| 147 | {name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }}, | 147 | {name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }}, | ||
| 148 | {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }}, | 148 | {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }}, | ||
| 149 | + | {name: "invalid business logic mode", mutate: func(c *Config) { c.Heuristics.BusinessLogicMode = "loud" }}, | |||
| 150 | + | {name: "invalid business logic min confidence", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinConfidence = "sure" }}, | |||
| 149 | } | 151 | } | ||
| 150 | for _, tt := range tests { | 152 | for _, tt := range tests { | ||
| 151 | t.Run(tt.name, func(t *testing.T) { | 153 | t.Run(tt.name, func(t *testing.T) { | ||
M examples/hexcheck.yaml +2 -0
| @@ -69,6 +69,8 @@ heuristics: | |||
| 69 | 69 | businessLogicMinWeakSignals: 2 | |
| 70 | 70 | businessLogicMaxFunctionNodes: 2000 | |
| 71 | 71 | businessLogicMaxDiagnosticsPerPackage: 10 | |
| 72 | + | businessLogicMode: audit | |
| 73 | + | businessLogicMinConfidence: medium | |
| 72 | 74 | excludeTestFiles: true | |
| 73 | 75 | businessKeywords: | |
| 74 | 76 | - Validate | |
| @@ -69,6 +69,8 @@ heuristics: | |||||
| 69 | businessLogicMinWeakSignals: 2 | 69 | businessLogicMinWeakSignals: 2 | ||
| 70 | businessLogicMaxFunctionNodes: 2000 | 70 | businessLogicMaxFunctionNodes: 2000 | ||
| 71 | businessLogicMaxDiagnosticsPerPackage: 10 | 71 | businessLogicMaxDiagnosticsPerPackage: 10 | ||
| 72 | + | businessLogicMode: audit | |||
| 73 | + | businessLogicMinConfidence: medium | |||
| 72 | excludeTestFiles: true | 74 | excludeTestFiles: true | ||
| 73 | businessKeywords: | 75 | businessKeywords: | ||
| 74 | - Validate | 76 | - Validate | ||