detached 92ac699

Merge pull request #1 from bnema/feat/business-logic-evidence

Improve business logic detection confidence
commit
92ac699000b59707bdf19511c3366112b1374f07
author
bnema · · 2026-05-30 13:16 UTC
committer
GitHub · · 2026-05-30 13:16 UTC
merge of
b9fa58d + 6954c9e · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
refs
tag: v0.2.0
17 files · +377 -22
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.go +9 -7
@@ -116,10 +116,10 @@ func discoverModulePath(root string) string {
116116 if err != nil {
117117 return ""
118118 }
119- for _, line := range strings.Split(string(data), "\n") {
119+ for line := range strings.SplitSeq(string(data), "\n") {
120120 line = strings.TrimSpace(line)
121- if strings.HasPrefix(line, "module ") {
122- return strings.TrimSpace(strings.TrimPrefix(line, "module "))
121+ if modulePath, ok := strings.CutPrefix(line, "module "); ok {
122+ return strings.TrimSpace(modulePath)
123123 }
124124 }
125125 return ""
@@ -282,8 +282,10 @@ func (r runner) isExcluded(rule, filePath string) bool {
282282 }
283283
284284 func (r runner) relImportPath(importPath string) string {
285- if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {
286- return strings.TrimPrefix(importPath, r.modulePath+"/")
285+ if r.modulePath != "" {
286+ if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
287+ return rel
288+ }
287289 }
288290 if r.modulePath != "" && importPath == r.modulePath {
289291 return ""
@@ -299,8 +301,8 @@ func (r runner) filePath(file *ast.File) string {
299301 }
300302 if r.cfg.Root != "" {
301303 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
302- if strings.HasPrefix(pos, root+"/") {
303- return strings.TrimPrefix(pos, root+"/")
304+ if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
305+ return rel
304306 }
305307 }
306308 return pos
@@ -116,10 +116,10 @@ func discoverModulePath(root string) string {
116 if err != nil {116 if err != nil {
117 return ""117 return ""
118 }118 }
119- for _, line := range strings.Split(string(data), "\n") {119+ for line := range strings.SplitSeq(string(data), "\n") {
120 line = strings.TrimSpace(line)120 line = strings.TrimSpace(line)
121- if strings.HasPrefix(line, "module ") {121+ if modulePath, ok := strings.CutPrefix(line, "module "); ok {
122- return strings.TrimSpace(strings.TrimPrefix(line, "module "))122+ return strings.TrimSpace(modulePath)
123 }123 }
124 }124 }
125 return ""125 return ""
@@ -282,8 +282,10 @@ func (r runner) isExcluded(rule, filePath string) bool {
282 }282 }
283 283
284 func (r runner) relImportPath(importPath string) string {284 func (r runner) relImportPath(importPath string) string {
285- if r.modulePath != "" && strings.HasPrefix(importPath, r.modulePath+"/") {285+ if r.modulePath != "" {
286- return strings.TrimPrefix(importPath, r.modulePath+"/")286+ if rel, ok := strings.CutPrefix(importPath, r.modulePath+"/"); ok {
287+ return rel
288+ }
287 }289 }
288 if r.modulePath != "" && importPath == r.modulePath {290 if r.modulePath != "" && importPath == r.modulePath {
289 return ""291 return ""
@@ -299,8 +301,8 @@ func (r runner) filePath(file *ast.File) string {
299 }301 }
300 if r.cfg.Root != "" {302 if r.cfg.Root != "" {
301 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")303 root := strings.ReplaceAll(r.cfg.Root, "\\", "/")
302- if strings.HasPrefix(pos, root+"/") {304+ if rel, ok := strings.CutPrefix(pos, root+"/"); ok {
303- return strings.TrimPrefix(pos, root+"/")305+ return rel
304 }306 }
305 }307 }
306 return pos308 return pos
M analyzer/analyzer_test.go +4 -0
@@ -24,6 +24,7 @@ func TestAnalyzer(t *testing.T) {
2424 name: "heuristics",
2525 patterns: []string{
2626 "example.com/project/internal/infrastructure/http",
27+ "example.com/project/cmd/app",
2728 },
2829 },
2930 {
@@ -53,6 +54,7 @@ func testConfig() *config.Config {
5354 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
5455 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
5556 "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},
57+ "cmd": {Role: config.RoleEntrypoint, Paths: []string{"cmd/**"}},
5658 },
5759 Rules: config.DefaultRuleSeverities(),
5860 Heuristics: config.Heuristics{
@@ -61,6 +63,8 @@ func testConfig() *config.Config {
6163 BusinessLogicMinWeakSignals: testIntPtr(2),
6264 BusinessLogicMaxFunctionNodes: testIntPtr(2000),
6365 BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
66+ BusinessLogicMode: "audit",
67+ BusinessLogicMinConfidence: "medium",
6468 BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"},
6569 },
6670 ExternalTypes: config.ExternalTypes{
@@ -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{
@@ -61,6 +63,8 @@ func testConfig() *config.Config {
61 BusinessLogicMinWeakSignals: testIntPtr(2),63 BusinessLogicMinWeakSignals: testIntPtr(2),
62 BusinessLogicMaxFunctionNodes: testIntPtr(2000),64 BusinessLogicMaxFunctionNodes: testIntPtr(2000),
63 BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),65 BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
66+ BusinessLogicMode: "audit",
67+ BusinessLogicMinConfidence: "medium",
64 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"},
65 },69 },
66 ExternalTypes: config.ExternalTypes{70 ExternalTypes: config.ExternalTypes{
M analyzer/business_logic.go +115 -10
@@ -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,28 +40,46 @@ 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, current.Role) {
3544 *packageDiagnostics++
3645 }
3746 }
3847 }
3948
40-func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, filePath string) bool {
41- facts := businessFunctionFacts{runner: r, fn: fn}
42- ast.Inspect(fn.Body, facts.inspect)
49+func (r runner) isSuspiciousBusinessFunction(fn *ast.FuncDecl, file *ast.File, filePath string, fileRole config.Role) bool {
50+ facts := r.collectBusinessFunctionFacts(fn, fileRole, file)
4351 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
4452 return false
4553 }
4654 strong, weak := facts.classify()
47- if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) {
48- return r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has business-logic evidence: strong=%v weak=%v", fn.Name.Name, signalKinds(strong), signalKinds(weak))
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))
4961 }
5062 return false
5163 }
5264
65+func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.Role, file ...*ast.File) businessFunctionFacts {
66+ if fileRole == "" {
67+ fileRole = config.RoleAdapter
68+ }
69+ facts := businessFunctionFacts{runner: r, fn: fn, fileRole: fileRole, imports: map[string]bool{}}
70+ if len(file) > 0 && file[0] != nil {
71+ for _, imp := range file[0].Imports {
72+ facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
73+ }
74+ }
75+ ast.Inspect(fn.Body, facts.inspect)
76+ return facts
77+}
78+
5379 type businessFunctionFacts struct {
54- runner runner
55- fn *ast.FuncDecl
80+ runner runner
81+ fn *ast.FuncDecl
82+ fileRole config.Role
5683
5784 nodeCount int
5885 branchCount int
@@ -64,6 +91,8 @@ type businessFunctionFacts struct {
6491 domainErrorReturn bool
6592 policyConstant bool
6693 usecaseCalls map[string]bool
94+ imports map[string]bool
95+ usesTechnicalPackage bool
6796 }
6897
6998 func (f *businessFunctionFacts) inspect(n ast.Node) bool {
@@ -105,6 +134,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
105134 }
106135
107136 func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
137+ f.inspectTechnicalCall(call)
108138 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
109139 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
110140 f.domainConstructorCall = true
@@ -127,6 +157,32 @@ func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
127157 }
128158 }
129159
160+func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) {
161+ sel, ok := call.Fun.(*ast.SelectorExpr)
162+ if !ok {
163+ return
164+ }
165+ ident, ok := sel.X.(*ast.Ident)
166+ if !ok || f.runner.pass == nil || f.runner.pass.TypesInfo == nil {
167+ return
168+ }
169+ pkgName, ok := f.runner.pass.TypesInfo.ObjectOf(ident).(*types.PkgName)
170+ if !ok || pkgName.Imported() == nil {
171+ return
172+ }
173+ path := pkgName.Imported().Path()
174+ if technicalImportPaths[path] && f.imports[path] {
175+ f.usesTechnicalPackage = true
176+ }
177+}
178+
179+var technicalImportPaths = map[string]bool{
180+ "os": true,
181+ "path/filepath": true,
182+ "strconv": true,
183+ "bufio": true,
184+}
185+
130186 func collaboratorKey(info *types.Info, expr ast.Expr) string {
131187 if ident, ok := expr.(*ast.Ident); ok {
132188 if obj := info.ObjectOf(ident); obj != nil {
@@ -136,6 +192,55 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string {
136192 return fmt.Sprintf("%p", expr)
137193 }
138194
195+func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence {
196+ if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals {
197+ return businessLogicConfidenceHigh
198+ }
199+ if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
200+ return businessLogicConfidenceMedium
201+ }
202+ if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
203+ return businessLogicConfidenceLow
204+ }
205+ return businessLogicConfidenceNone
206+}
207+
208+func effectiveBusinessLogicMinConfidence(cfg *config.Config) string {
209+ min := cfg.Heuristics.BusinessLogicMinConfidence
210+ if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) {
211+ return string(businessLogicConfidenceHigh)
212+ }
213+ return min
214+}
215+
216+func confidenceMeets(got businessLogicConfidence, min string) bool {
217+ levels := map[businessLogicConfidence]int{
218+ businessLogicConfidenceNone: 0,
219+ businessLogicConfidenceLow: 1,
220+ businessLogicConfidenceMedium: 2,
221+ businessLogicConfidenceHigh: 3,
222+ }
223+ want, ok := levels[businessLogicConfidence(min)]
224+ if !ok {
225+ want = levels[businessLogicConfidenceLow]
226+ }
227+ return levels[got] >= want
228+}
229+
230+func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool {
231+ if !containsWord(f.fn.Name.Name, "Detect") {
232+ return false
233+ }
234+ if !f.usesTechnicalPackage {
235+ return false
236+ }
237+ return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0
238+}
239+
240+func (f *businessFunctionFacts) componentRole() config.Role {
241+ return f.fileRole
242+}
243+
139244 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140245 strong := []businessLogicSignal{}
141246 weak := []businessLogicSignal{}
@@ -236,8 +341,8 @@ func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool
236341 case *types.Signature:
237342 return f.typeHasRole(tt.Results(), role)
238343 case *types.Tuple:
239- for i := 0; i < tt.Len(); i++ {
240- if f.typeHasRole(tt.At(i).Type(), role) {
344+ for v := range tt.Variables() {
345+ if f.typeHasRole(v.Type(), role) {
241346 return true
242347 }
243348 }
@@ -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,28 +40,46 @@ 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, current.Role) {
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, fileRole config.Role) bool {
41- facts := businessFunctionFacts{runner: r, fn: fn}50+ facts := r.collectBusinessFunctionFacts(fn, fileRole, file)
42- ast.Inspect(fn.Body, facts.inspect)
43 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {51 if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
44 return false52 return false
45 }53 }
46 strong, weak := facts.classify()54 strong, weak := facts.classify()
47- if len(strong) >= *r.cfg.Heuristics.BusinessLogicMinStrongSignals || (len(strong) >= 1 && len(weak) >= *r.cfg.Heuristics.BusinessLogicMinWeakSignals) {55+ if facts.isTechnicalDetectionOnly() {
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))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))
49 }61 }
50 return false62 return false
51 }63 }
52 64
65+func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.Role, file ...*ast.File) businessFunctionFacts {
66+ if fileRole == "" {
67+ fileRole = config.RoleAdapter
68+ }
69+ facts := businessFunctionFacts{runner: r, fn: fn, fileRole: fileRole, imports: map[string]bool{}}
70+ if len(file) > 0 && file[0] != nil {
71+ for _, imp := range file[0].Imports {
72+ facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
73+ }
74+ }
75+ ast.Inspect(fn.Body, facts.inspect)
76+ return facts
77+}
78+
53 type businessFunctionFacts struct {79 type businessFunctionFacts struct {
54- runner runner80+ runner runner
55- fn *ast.FuncDecl81+ fn *ast.FuncDecl
82+ fileRole config.Role
56 83
57 nodeCount int84 nodeCount int
58 branchCount int85 branchCount int
@@ -64,6 +91,8 @@ type businessFunctionFacts struct {
64 domainErrorReturn bool91 domainErrorReturn bool
65 policyConstant bool92 policyConstant bool
66 usecaseCalls map[string]bool93 usecaseCalls map[string]bool
94+ imports map[string]bool
95+ usesTechnicalPackage bool
67 }96 }
68 97
69 func (f *businessFunctionFacts) inspect(n ast.Node) bool {98 func (f *businessFunctionFacts) inspect(n ast.Node) bool {
@@ -105,6 +134,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
105 }134 }
106 135
107 func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {136 func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
137+ f.inspectTechnicalCall(call)
108 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)138 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
109 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {139 if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
110 f.domainConstructorCall = true140 f.domainConstructorCall = true
@@ -127,6 +157,32 @@ func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
127 }157 }
128 }158 }
129 159
160+func (f *businessFunctionFacts) inspectTechnicalCall(call *ast.CallExpr) {
161+ sel, ok := call.Fun.(*ast.SelectorExpr)
162+ if !ok {
163+ return
164+ }
165+ ident, ok := sel.X.(*ast.Ident)
166+ if !ok || f.runner.pass == nil || f.runner.pass.TypesInfo == nil {
167+ return
168+ }
169+ pkgName, ok := f.runner.pass.TypesInfo.ObjectOf(ident).(*types.PkgName)
170+ if !ok || pkgName.Imported() == nil {
171+ return
172+ }
173+ path := pkgName.Imported().Path()
174+ if technicalImportPaths[path] && f.imports[path] {
175+ f.usesTechnicalPackage = true
176+ }
177+}
178+
179+var technicalImportPaths = map[string]bool{
180+ "os": true,
181+ "path/filepath": true,
182+ "strconv": true,
183+ "bufio": true,
184+}
185+
130 func collaboratorKey(info *types.Info, expr ast.Expr) string {186 func collaboratorKey(info *types.Info, expr ast.Expr) string {
131 if ident, ok := expr.(*ast.Ident); ok {187 if ident, ok := expr.(*ast.Ident); ok {
132 if obj := info.ObjectOf(ident); obj != nil {188 if obj := info.ObjectOf(ident); obj != nil {
@@ -136,6 +192,55 @@ func collaboratorKey(info *types.Info, expr ast.Expr) string {
136 return fmt.Sprintf("%p", expr)192 return fmt.Sprintf("%p", expr)
137 }193 }
138 194
195+func (f *businessFunctionFacts) businessLogicConfidence(strong, weak []businessLogicSignal) businessLogicConfidence {
196+ if len(strong) >= *f.runner.cfg.Heuristics.BusinessLogicMinStrongSignals {
197+ return businessLogicConfidenceHigh
198+ }
199+ if len(strong) >= 1 && len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
200+ return businessLogicConfidenceMedium
201+ }
202+ if len(strong) >= 1 || len(weak) >= *f.runner.cfg.Heuristics.BusinessLogicMinWeakSignals {
203+ return businessLogicConfidenceLow
204+ }
205+ return businessLogicConfidenceNone
206+}
207+
208+func effectiveBusinessLogicMinConfidence(cfg *config.Config) string {
209+ min := cfg.Heuristics.BusinessLogicMinConfidence
210+ if cfg.Heuristics.BusinessLogicMode == "ci" && confidenceMeets(businessLogicConfidenceHigh, min) {
211+ return string(businessLogicConfidenceHigh)
212+ }
213+ return min
214+}
215+
216+func confidenceMeets(got businessLogicConfidence, min string) bool {
217+ levels := map[businessLogicConfidence]int{
218+ businessLogicConfidenceNone: 0,
219+ businessLogicConfidenceLow: 1,
220+ businessLogicConfidenceMedium: 2,
221+ businessLogicConfidenceHigh: 3,
222+ }
223+ want, ok := levels[businessLogicConfidence(min)]
224+ if !ok {
225+ want = levels[businessLogicConfidenceLow]
226+ }
227+ return levels[got] >= want
228+}
229+
230+func (f *businessFunctionFacts) isTechnicalDetectionOnly() bool {
231+ if !containsWord(f.fn.Name.Name, "Detect") {
232+ return false
233+ }
234+ if !f.usesTechnicalPackage {
235+ return false
236+ }
237+ return !f.domainTypeUse && !f.domainMutation && !f.domainMethodCall && !f.domainConstructorCall && !f.domainErrorReturn && len(f.usecaseCalls) == 0
238+}
239+
240+func (f *businessFunctionFacts) componentRole() config.Role {
241+ return f.fileRole
242+}
243+
139 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {244 func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140 strong := []businessLogicSignal{}245 strong := []businessLogicSignal{}
141 weak := []businessLogicSignal{}246 weak := []businessLogicSignal{}
@@ -236,8 +341,8 @@ func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool
236 case *types.Signature:341 case *types.Signature:
237 return f.typeHasRole(tt.Results(), role)342 return f.typeHasRole(tt.Results(), role)
238 case *types.Tuple:343 case *types.Tuple:
239- for i := 0; i < tt.Len(); i++ {344+ for v := range tt.Variables() {
240- if f.typeHasRole(tt.At(i).Type(), role) {345+ if f.typeHasRole(v.Type(), role) {
241 return true346 return true
242 }347 }
243 }348 }
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_facts_test.go +39 -0
@@ -0,0 +1,39 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/types"
6+ "testing"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) {
13+ fn := &ast.FuncDecl{
14+ Name: ast.NewIdent("ResolveMode"),
15+ Type: &ast.FuncType{},
16+ Body: &ast.BlockStmt{List: []ast.Stmt{
17+ &ast.IfStmt{
18+ Cond: ast.NewIdent("ok"),
19+ Body: &ast.BlockStmt{},
20+ },
21+ }},
22+ }
23+ r := runner{
24+ cfg: &config.Config{Heuristics: config.Heuristics{BusinessLogicMaxFunctionNodes: testIntPtr(2000)}},
25+ pass: &analysis.Pass{TypesInfo: &types.Info{Types: map[ast.Expr]types.TypeAndValue{}}},
26+ }
27+
28+ facts := r.collectBusinessFunctionFacts(fn, config.RoleAdapter)
29+
30+ if facts.fn != fn {
31+ t.Fatalf("collectBusinessFunctionFacts() fn = %p, want %p", facts.fn, fn)
32+ }
33+ if facts.branchCount != 1 {
34+ t.Fatalf("collectBusinessFunctionFacts() branchCount = %d, want 1", facts.branchCount)
35+ }
36+ if facts.nodeCount == 0 {
37+ t.Fatal("collectBusinessFunctionFacts() nodeCount = 0, want inspected nodes")
38+ }
39+}
@@ -0,0 +1,39 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/types"
6+ "testing"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) {
13+ fn := &ast.FuncDecl{
14+ Name: ast.NewIdent("ResolveMode"),
15+ Type: &ast.FuncType{},
16+ Body: &ast.BlockStmt{List: []ast.Stmt{
17+ &ast.IfStmt{
18+ Cond: ast.NewIdent("ok"),
19+ Body: &ast.BlockStmt{},
20+ },
21+ }},
22+ }
23+ r := runner{
24+ cfg: &config.Config{Heuristics: config.Heuristics{BusinessLogicMaxFunctionNodes: testIntPtr(2000)}},
25+ pass: &analysis.Pass{TypesInfo: &types.Info{Types: map[ast.Expr]types.TypeAndValue{}}},
26+ }
27+
28+ facts := r.collectBusinessFunctionFacts(fn, config.RoleAdapter)
29+
30+ if facts.fn != fn {
31+ t.Fatalf("collectBusinessFunctionFacts() fn = %p, want %p", facts.fn, fn)
32+ }
33+ if facts.branchCount != 1 {
34+ t.Fatalf("collectBusinessFunctionFacts() branchCount = %d, want 1", facts.branchCount)
35+ }
36+ if facts.nodeCount == 0 {
37+ t.Fatal("collectBusinessFunctionFacts() nodeCount = 0, want inspected nodes")
38+ }
39+}
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/cmd/app/main.go +17 -0
@@ -0,0 +1,17 @@
1+package main
2+
3+import "example.com/project/internal/domain"
4+
5+func ResolveStartupMode(user *domain.User) string { // want "suspicious-business-logic-in-adapter: entrypoint function ResolveStartupMode"
6+ user.Name = "seen"
7+ if user.Name == "admin" {
8+ return "admin"
9+ }
10+ if user.Name == "guest" {
11+ return "guest"
12+ }
13+ if user.Name == "" {
14+ return "empty"
15+ }
16+ return "normal"
17+}
@@ -0,0 +1,17 @@
1+package main
2+
3+import "example.com/project/internal/domain"
4+
5+func ResolveStartupMode(user *domain.User) string { // want "suspicious-business-logic-in-adapter: entrypoint function ResolveStartupMode"
6+ user.Name = "seen"
7+ if user.Name == "admin" {
8+ return "admin"
9+ }
10+ if user.Name == "guest" {
11+ return "guest"
12+ }
13+ if user.Name == "" {
14+ return "empty"
15+ }
16+ return "normal"
17+}
M analyzer/testdata/src/example.com/project/internal/infrastructure/http/logic.go +1 -1
@@ -2,7 +2,7 @@ package http
22
33 import "example.com/project/internal/domain"
44
5-func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"
5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter: adapter function ValidateOrder"
66 if order.Name == "" {
77 return domain.PolicyError{}
88 }
@@ -2,7 +2,7 @@ package http
2 2
3 import "example.com/project/internal/domain"3 import "example.com/project/internal/domain"
4 4
5-func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter: adapter function ValidateOrder"
6 if order.Name == "" {6 if order.Name == "" {
7 return domain.PolicyError{}7 return domain.PolicyError{}
8 }8 }
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