detached dee20a2

feat(analyzer): add business logic confidence controls

commit
dee20a21f2323b90ec8b28a855dad050ee808cf1
author
brice · · 2026-05-30 11:10 UTC
parent
9ad764f
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
13 files · +290 -11
M README.md +4 -0
@@ -81,6 +81,8 @@ heuristics:
8181 businessLogicMinWeakSignals: 2
8282 businessLogicMaxFunctionNodes: 2000
8383 businessLogicMaxDiagnosticsPerPackage: 10
84+ businessLogicMode: audit # audit|ci
85+ businessLogicMinConfidence: medium # low|medium|high
8486 excludeTestFiles: true
8587
8688 mocking:
@@ -95,6 +97,8 @@ mocking:
9597
9698 A fuller example lives in [`examples/hexcheck.yaml`](examples/hexcheck.yaml).
9799
100+Business-logic diagnostics include a confidence level. `audit` mode reports findings at or above `businessLogicMinConfidence`; `ci` mode reports only high-confidence findings.
101+
98102 ## Standalone CLI
99103
100104 Because `hexcheck` is a `go/analysis` analyzer, standalone flags use the analyzer prefix:
@@ -81,6 +81,8 @@ heuristics:
81 businessLogicMinWeakSignals: 281 businessLogicMinWeakSignals: 2
82 businessLogicMaxFunctionNodes: 200082 businessLogicMaxFunctionNodes: 2000
83 businessLogicMaxDiagnosticsPerPackage: 1083 businessLogicMaxDiagnosticsPerPackage: 10
84+ businessLogicMode: audit # audit|ci
85+ businessLogicMinConfidence: medium # low|medium|high
84 excludeTestFiles: true86 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 CLI102 ## 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:
4545 prefer-generated-mocks: warn
4646 ```
4747
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+
4858 Mock config:
4959
5060 ```yaml
@@ -45,6 +45,16 @@ rules:
45 prefer-generated-mocks: warn45 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 ```yaml60 ```yaml
M analyzer/analyzer_test.go +2 -0
@@ -63,6 +63,8 @@ func testConfig() *config.Config {
6363 BusinessLogicMinWeakSignals: testIntPtr(2),
6464 BusinessLogicMaxFunctionNodes: testIntPtr(2000),
6565 BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
66+ BusinessLogicMode: "audit",
67+ BusinessLogicMinConfidence: "medium",
6668 BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"},
6769 },
6870 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 (
1010 "github.com/bnema/hexcheck/config"
1111 )
1212
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+
1322 type businessLogicSignal struct {
1423 kind string
1524 strong bool
@@ -31,26 +40,35 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
3140 if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
3241 return
3342 }
34- if r.isSuspiciousBusinessFunction(fn, filePath) {
43+ if r.isSuspiciousBusinessFunction(fn, file, filePath) {
3544 *packageDiagnostics++
3645 }
3746 }
3847 }
3948
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)
4251 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
4352 return false
4453 }
4554 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))
4861 }
4962 return false
5063 }
5164
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+ }
5472 ast.Inspect(fn.Body, facts.inspect)
5573 return facts
5674 }
@@ -69,6 +87,8 @@ type businessFunctionFacts struct {
6987 domainErrorReturn bool
7088 policyConstant bool
7189 usecaseCalls map[string]bool
90+ imports map[string]bool
91+ usesTechnicalPackage bool
7292 }
7393
7494 func (f *businessFunctionFacts) inspect(n ast.Node) bool {
@@ -110,6 +130,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
110130 }
111131
112132 func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
133+ f.inspectTechnicalCall(call)
113134 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
114135 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
115136 f.domainConstructorCall = true
@@ -132,6 +153,29 @@ func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
132153 }
133154 }
134155
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+
135179 func collaboratorKey(info *types.Info, expr ast.Expr) string {
136180 if ident, ok := expr.(*ast.Ident); ok {
137181 if obj := info.ObjectOf(ident); obj != nil {
@@ -141,6 +185,51 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string {
141185 return fmt.Sprintf("%p", expr)
142186 }
143187
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+
144233 func (f *businessFunctionFacts) componentRole() config.Role {
145234 pkgRel := f.runner.relImportPath(f.runner.pass.Pkg.Path())
146235 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 string23 kind string
15 strong bool24 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 return41 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 false52 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 false62 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 facts73 return facts
56 }74 }
@@ -69,6 +87,8 @@ type businessFunctionFacts struct {
69 domainErrorReturn bool87 domainErrorReturn bool
70 policyConstant bool88 policyConstant bool
71 usecaseCalls map[string]bool89 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 = true136 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 @@
11 package analyzer
22
3-import "github.com/bnema/hexcheck/config"
3+import (
4+ "maps"
5+
6+ "github.com/bnema/hexcheck/config"
7+)
48
59 func cloneConfig(in *config.Config) *config.Config {
610 if in == nil {
@@ -13,9 +17,7 @@ func cloneConfig(in *config.Config) *config.Config {
1317 out.Components[name] = component
1418 }
1519 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)
1921 out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...)
2022 out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...)
2123 out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...)
@@ -24,6 +26,8 @@ func cloneConfig(in *config.Config) *config.Config {
2426 out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals)
2527 out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes)
2628 out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage)
29+ out.Heuristics.BusinessLogicMode = in.Heuristics.BusinessLogicMode
30+ out.Heuristics.BusinessLogicMinConfidence = in.Heuristics.BusinessLogicMinConfidence
2731 if in.Heuristics.ExcludeTestFiles != nil {
2832 value := *in.Heuristics.ExcludeTestFiles
2933 out.Heuristics.ExcludeTestFiles = &value
@@ -1,6 +1,10 @@
1 package analyzer1 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] = component17 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.ExcludeTestFiles32 value := *in.Heuristics.ExcludeTestFiles
29 out.Heuristics.ExcludeTestFiles = &value33 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 {
5858 BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"`
5959 BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"`
6060 BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"`
61+ BusinessLogicMode string `yaml:"businessLogicMode"`
62+ BusinessLogicMinConfidence string `yaml:"businessLogicMinConfidence"`
6163 BusinessKeywords []string `yaml:"businessKeywords"`
6264 ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
6365 }
@@ -147,6 +149,12 @@ func (c *Config) applyDefaults() {
147149 defaultExcludeTestFiles := true
148150 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles
149151 }
152+ if c.Heuristics.BusinessLogicMode == "" {
153+ c.Heuristics.BusinessLogicMode = "audit"
154+ }
155+ if c.Heuristics.BusinessLogicMinConfidence == "" {
156+ c.Heuristics.BusinessLogicMinConfidence = "medium"
157+ }
150158 if len(c.Heuristics.BusinessKeywords) == 0 {
151159 c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}
152160 }
@@ -220,6 +228,16 @@ func (c *Config) Validate() error {
220228 if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 {
221229 return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1")
222230 }
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+ }
223241 for rule, severity := range c.Rules {
224242 switch severity {
225243 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 := true149 defaultExcludeTestFiles := true
148 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles150 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) {
146146 {name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }},
147147 {name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }},
148148 {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" }},
149151 }
150152 for _, tt := range tests {
151153 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:
6969 businessLogicMinWeakSignals: 2
7070 businessLogicMaxFunctionNodes: 2000
7171 businessLogicMaxDiagnosticsPerPackage: 10
72+ businessLogicMode: audit
73+ businessLogicMinConfidence: medium
7274 excludeTestFiles: true
7375 businessKeywords:
7476 - Validate
@@ -69,6 +69,8 @@ heuristics:
69 businessLogicMinWeakSignals: 269 businessLogicMinWeakSignals: 2
70 businessLogicMaxFunctionNodes: 200070 businessLogicMaxFunctionNodes: 2000
71 businessLogicMaxDiagnosticsPerPackage: 1071 businessLogicMaxDiagnosticsPerPackage: 10
72+ businessLogicMode: audit
73+ businessLogicMinConfidence: medium
72 excludeTestFiles: true74 excludeTestFiles: true
73 businessKeywords:75 businessKeywords:
74 - Validate76 - Validate

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