detached 79118a8

feat(analyzer): calibrate business logic heuristic

commit
79118a8429eec94f601af156648625f4ef8764b7
author
brice · · 2026-05-30 09:00 UTC
parent
4de0705
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
refs
tag: v0.1.0
20 files · +759 -40
A CHANGELOG.md +14 -0
@@ -0,0 +1,14 @@
1+# Changelog
2+
3+## v0.1.0
4+
5+Initial release of `hexcheck`.
6+
7+- Adds a `go/analysis` analyzer for configurable hexagonal architecture checks.
8+- Adds a standalone `hexcheck` CLI.
9+- Adds golangci-lint Module Plugin integration.
10+- Adds `.hexcheck.yaml` component role mapping for `core`, `usecase`, `ports`, `adapter`, `entrypoint`, and `ignore`.
11+- Adds deterministic boundary rules for adapter imports, usecase imports, framework type leaks, port type leaks, and adapter coupling.
12+- Adds warning-level adapter business-logic heuristics with package-local AST/type analysis and performance guardrails.
13+- Adds mock discipline rules for missing generated mocks, local fakes, and concrete adapters in usecase/core tests.
14+- Adds example configuration and an agent-facing `SKILL.md` for configuring new repositories.
@@ -0,0 +1,14 @@
1+# Changelog
2+
3+## v0.1.0
4+
5+Initial release of `hexcheck`.
6+
7+- Adds a `go/analysis` analyzer for configurable hexagonal architecture checks.
8+- Adds a standalone `hexcheck` CLI.
9+- Adds golangci-lint Module Plugin integration.
10+- Adds `.hexcheck.yaml` component role mapping for `core`, `usecase`, `ports`, `adapter`, `entrypoint`, and `ignore`.
11+- Adds deterministic boundary rules for adapter imports, usecase imports, framework type leaks, port type leaks, and adapter coupling.
12+- Adds warning-level adapter business-logic heuristics with package-local AST/type analysis and performance guardrails.
13+- Adds mock discipline rules for missing generated mocks, local fakes, and concrete adapters in usecase/core tests.
14+- Adds example configuration and an agent-facing `SKILL.md` for configuring new repositories.
A SKILL.md +175 -0
@@ -0,0 +1,175 @@
1+---
2+name: hexcheck
3+description: Use when configuring or running hexcheck in a Go repository to enforce hexagonal architecture boundaries, adapter business-logic warnings, and mock discipline.
4+---
5+
6+# Hexcheck Configuration Skill
7+
8+Use `hexcheck` to lint Go repositories for hexagonal / clean architecture violations.
9+
10+## Core idea
11+
12+`hexcheck` does not require folders to have fixed names. It maps project-specific paths to architecture roles through `.hexcheck.yaml`.
13+
14+Supported roles:
15+
16+- `core` — domain entities, value objects, domain services, core business logic
17+- `usecase` — application services/use cases/orchestration
18+- `ports` — interfaces/contracts that core/usecases depend on
19+- `adapter` — infrastructure, persistence, external services, outbound adapters
20+- `entrypoint` — CLI, HTTP handlers, UI entrypoints, app bootstrap
21+- `ignore` — generated code, mocks, vendored or ignored paths
22+
23+Component names are arbitrary. The `role` is what matters.
24+
25+## Example mappings
26+
27+A repo with `domain`, `application`, and `infrastructure`:
28+
29+```yaml
30+components:
31+ domain:
32+ role: core
33+ paths:
34+ - internal/domain/**
35+ usecases:
36+ role: usecase
37+ paths:
38+ - internal/application/usecase/**
39+ - internal/application/usecases/**
40+ ports:
41+ role: ports
42+ paths:
43+ - internal/application/port/**
44+ infrastructure:
45+ role: adapter
46+ paths:
47+ - internal/infrastructure/**
48+ cli:
49+ role: entrypoint
50+ paths:
51+ - cmd/**
52+ - internal/cli/**
53+```
54+
55+A repo that uses `core` and `boundaries`:
56+
57+```yaml
58+components:
59+ core:
60+ role: core
61+ paths:
62+ - internal/core/**
63+ usecases:
64+ role: usecase
65+ paths:
66+ - internal/usecases/**
67+ inboundBoundaries:
68+ role: entrypoint
69+ paths:
70+ - internal/boundaries/in/**
71+ outboundBoundaries:
72+ role: adapter
73+ paths:
74+ - internal/boundaries/out/**
75+ contracts:
76+ role: ports
77+ paths:
78+ - internal/boundaries/ports/**
79+```
80+
81+## Recommended defaults
82+
83+Start strict on deterministic rules and warning-first on heuristics:
84+
85+```yaml
86+rules:
87+ no-adapter-imports-in-core: error
88+ no-infra-imports-in-usecase: error
89+ no-framework-types-in-core: error
90+ no-infra-types-in-ports: error
91+ no-adapter-to-adapter-imports: warn
92+ suspicious-business-logic-in-adapter: warn
93+ no-local-fakes-for-ports: warn
94+ missing-generated-mock-for-port: warn
95+ prefer-generated-mocks: warn
96+```
97+
98+## Business-logic heuristic
99+
100+`suspicious-business-logic-in-adapter` is cumulative and package-local. It should not scan the whole repository. It combines strong and weak evidence:
101+
102+- business keywords such as `Validate`, `Migrate`, `Resolve`, `Score`, `Restore`, `Purge`, `Update`
103+- policy constants and branching
104+- domain/core mutation or method calls
105+- multiple port/usecase collaborators when combined with business wording
106+
107+Use `excludePaths` for adapter areas where warnings are not useful:
108+
109+```yaml
110+ruleSettings:
111+ suspicious-business-logic-in-adapter:
112+ excludePaths:
113+ - internal/ui/generated/**
114+```
115+
116+## Generated code
117+
118+Always ignore generated code and mocks:
119+
120+```yaml
121+components:
122+ generated:
123+ role: ignore
124+ paths:
125+ - '**/mocks/**'
126+ - '**/generated/**'
127+ - '**/*_templ.go'
128+ - '**/*_gen.go'
129+```
130+
131+`hexcheck` also ignores files with a standard `// Code generated ... DO NOT EDIT.` comment.
132+
133+## Mock discipline
134+
135+Configure generated mock locations so `hexcheck` can distinguish missing mocks from local fake misuse:
136+
137+```yaml
138+mocking:
139+ generatedMockPaths:
140+ - internal/mocks/**
141+ - internal/application/mocks/**
142+ - internal/application/port/mocks/**
143+ - internal/domain/repository/mocks/**
144+ generatedMockNamePatterns:
145+ - Mock{{Interface}}
146+ - '{{Interface}}Mock'
147+```
148+
149+Expected behavior:
150+
151+- `missing-generated-mock-for-port` flags mockable interfaces without generated mocks.
152+- `no-local-fakes-for-ports` flags local fakes only when a generated mock exists.
153+- Intentional exceptions should use project config `allow` entries or linter suppression comments.
154+
155+## Running
156+
157+Standalone:
158+
159+```bash
160+hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
161+```
162+
163+With a custom golangci-lint binary, use the module plugin config from `examples/custom-gcl.yml` and `examples/golangci.yml`.
164+
165+## Agent checklist
166+
167+When configuring a new repo:
168+
169+1. Read architecture docs (`AGENTS.md`, `README`, docs) before guessing paths.
170+2. Map local folder names to roles; do not force folder renames.
171+3. Add generated/mocks ignore paths.
172+4. Add mock paths and name patterns.
173+5. Run `hexcheck` once and inspect counts by rule.
174+6. Tune `ruleSettings.*.excludePaths` for known technical adapters only after reviewing examples.
175+7. Keep deterministic rules as `error`; keep heuristic rules as `warn` until signal is proven.
@@ -0,0 +1,175 @@
1+---
2+name: hexcheck
3+description: Use when configuring or running hexcheck in a Go repository to enforce hexagonal architecture boundaries, adapter business-logic warnings, and mock discipline.
4+---
5+
6+# Hexcheck Configuration Skill
7+
8+Use `hexcheck` to lint Go repositories for hexagonal / clean architecture violations.
9+
10+## Core idea
11+
12+`hexcheck` does not require folders to have fixed names. It maps project-specific paths to architecture roles through `.hexcheck.yaml`.
13+
14+Supported roles:
15+
16+- `core` — domain entities, value objects, domain services, core business logic
17+- `usecase` — application services/use cases/orchestration
18+- `ports` — interfaces/contracts that core/usecases depend on
19+- `adapter` — infrastructure, persistence, external services, outbound adapters
20+- `entrypoint` — CLI, HTTP handlers, UI entrypoints, app bootstrap
21+- `ignore` — generated code, mocks, vendored or ignored paths
22+
23+Component names are arbitrary. The `role` is what matters.
24+
25+## Example mappings
26+
27+A repo with `domain`, `application`, and `infrastructure`:
28+
29+```yaml
30+components:
31+ domain:
32+ role: core
33+ paths:
34+ - internal/domain/**
35+ usecases:
36+ role: usecase
37+ paths:
38+ - internal/application/usecase/**
39+ - internal/application/usecases/**
40+ ports:
41+ role: ports
42+ paths:
43+ - internal/application/port/**
44+ infrastructure:
45+ role: adapter
46+ paths:
47+ - internal/infrastructure/**
48+ cli:
49+ role: entrypoint
50+ paths:
51+ - cmd/**
52+ - internal/cli/**
53+```
54+
55+A repo that uses `core` and `boundaries`:
56+
57+```yaml
58+components:
59+ core:
60+ role: core
61+ paths:
62+ - internal/core/**
63+ usecases:
64+ role: usecase
65+ paths:
66+ - internal/usecases/**
67+ inboundBoundaries:
68+ role: entrypoint
69+ paths:
70+ - internal/boundaries/in/**
71+ outboundBoundaries:
72+ role: adapter
73+ paths:
74+ - internal/boundaries/out/**
75+ contracts:
76+ role: ports
77+ paths:
78+ - internal/boundaries/ports/**
79+```
80+
81+## Recommended defaults
82+
83+Start strict on deterministic rules and warning-first on heuristics:
84+
85+```yaml
86+rules:
87+ no-adapter-imports-in-core: error
88+ no-infra-imports-in-usecase: error
89+ no-framework-types-in-core: error
90+ no-infra-types-in-ports: error
91+ no-adapter-to-adapter-imports: warn
92+ suspicious-business-logic-in-adapter: warn
93+ no-local-fakes-for-ports: warn
94+ missing-generated-mock-for-port: warn
95+ prefer-generated-mocks: warn
96+```
97+
98+## Business-logic heuristic
99+
100+`suspicious-business-logic-in-adapter` is cumulative and package-local. It should not scan the whole repository. It combines strong and weak evidence:
101+
102+- business keywords such as `Validate`, `Migrate`, `Resolve`, `Score`, `Restore`, `Purge`, `Update`
103+- policy constants and branching
104+- domain/core mutation or method calls
105+- multiple port/usecase collaborators when combined with business wording
106+
107+Use `excludePaths` for adapter areas where warnings are not useful:
108+
109+```yaml
110+ruleSettings:
111+ suspicious-business-logic-in-adapter:
112+ excludePaths:
113+ - internal/ui/generated/**
114+```
115+
116+## Generated code
117+
118+Always ignore generated code and mocks:
119+
120+```yaml
121+components:
122+ generated:
123+ role: ignore
124+ paths:
125+ - '**/mocks/**'
126+ - '**/generated/**'
127+ - '**/*_templ.go'
128+ - '**/*_gen.go'
129+```
130+
131+`hexcheck` also ignores files with a standard `// Code generated ... DO NOT EDIT.` comment.
132+
133+## Mock discipline
134+
135+Configure generated mock locations so `hexcheck` can distinguish missing mocks from local fake misuse:
136+
137+```yaml
138+mocking:
139+ generatedMockPaths:
140+ - internal/mocks/**
141+ - internal/application/mocks/**
142+ - internal/application/port/mocks/**
143+ - internal/domain/repository/mocks/**
144+ generatedMockNamePatterns:
145+ - Mock{{Interface}}
146+ - '{{Interface}}Mock'
147+```
148+
149+Expected behavior:
150+
151+- `missing-generated-mock-for-port` flags mockable interfaces without generated mocks.
152+- `no-local-fakes-for-ports` flags local fakes only when a generated mock exists.
153+- Intentional exceptions should use project config `allow` entries or linter suppression comments.
154+
155+## Running
156+
157+Standalone:
158+
159+```bash
160+hexcheck -hexcheck.config .hexcheck.yaml -hexcheck.root . ./...
161+```
162+
163+With a custom golangci-lint binary, use the module plugin config from `examples/custom-gcl.yml` and `examples/golangci.yml`.
164+
165+## Agent checklist
166+
167+When configuring a new repo:
168+
169+1. Read architecture docs (`AGENTS.md`, `README`, docs) before guessing paths.
170+2. Map local folder names to roles; do not force folder renames.
171+3. Add generated/mocks ignore paths.
172+4. Add mock paths and name patterns.
173+5. Run `hexcheck` once and inspect counts by rule.
174+6. Tune `ruleSettings.*.excludePaths` for known technical adapters only after reviewing examples.
175+7. Keep deterministic rules as `error`; keep heuristic rules as `warn` until signal is proven.
M analyzer/analyzer.go +9 -3
@@ -56,6 +56,10 @@ func New(opts Options) *analysis.Analyzer {
5656 func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
5757 if opts.Config != nil {
5858 cfg := cloneConfig(opts.Config)
59+ cfg.ApplyDefaults()
60+ if err := cfg.Validate(); err != nil {
61+ return nil, "", err
62+ }
5963 if cfg.Root != "" {
6064 root, err := filepath.Abs(cfg.Root)
6165 if err != nil {
@@ -142,6 +146,7 @@ func (r runner) run() {
142146 if !ok || current.Role == config.RoleIgnore {
143147 return
144148 }
149+ businessDiagnostics := 0
145150 for _, file := range r.pass.Files {
146151 filePath := r.filePath(file)
147152 if isGeneratedFile(file) {
@@ -150,7 +155,7 @@ func (r runner) run() {
150155 r.checkImports(file, filePath, current)
151156 r.checkTypeLeaks(file, filePath, current)
152157 r.checkMissingMocks(file, filePath, current)
153- r.checkBusinessLogic(file, filePath, current)
158+ r.checkBusinessLogic(file, filePath, current, &businessDiagnostics)
154159 r.checkMocks(file, filePath, current)
155160 }
156161 }
@@ -255,11 +260,12 @@ func (r runner) matchesExternal(pkg string) bool {
255260 return false
256261 }
257262
258-func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) {
263+func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
259264 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {
260- return
265+ return false
261266 }
262267 r.pass.Reportf(pos, rule+": "+format, args...)
268+ return true
263269 }
264270
265271 func (r runner) isExcluded(rule, filePath string) bool {
@@ -56,6 +56,10 @@ func New(opts Options) *analysis.Analyzer {
56 func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {56 func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) {
57 if opts.Config != nil {57 if opts.Config != nil {
58 cfg := cloneConfig(opts.Config)58 cfg := cloneConfig(opts.Config)
59+ cfg.ApplyDefaults()
60+ if err := cfg.Validate(); err != nil {
61+ return nil, "", err
62+ }
59 if cfg.Root != "" {63 if cfg.Root != "" {
60 root, err := filepath.Abs(cfg.Root)64 root, err := filepath.Abs(cfg.Root)
61 if err != nil {65 if err != nil {
@@ -142,6 +146,7 @@ func (r runner) run() {
142 if !ok || current.Role == config.RoleIgnore {146 if !ok || current.Role == config.RoleIgnore {
143 return147 return
144 }148 }
149+ businessDiagnostics := 0
145 for _, file := range r.pass.Files {150 for _, file := range r.pass.Files {
146 filePath := r.filePath(file)151 filePath := r.filePath(file)
147 if isGeneratedFile(file) {152 if isGeneratedFile(file) {
@@ -150,7 +155,7 @@ func (r runner) run() {
150 r.checkImports(file, filePath, current)155 r.checkImports(file, filePath, current)
151 r.checkTypeLeaks(file, filePath, current)156 r.checkTypeLeaks(file, filePath, current)
152 r.checkMissingMocks(file, filePath, current)157 r.checkMissingMocks(file, filePath, current)
153- r.checkBusinessLogic(file, filePath, current)158+ r.checkBusinessLogic(file, filePath, current, &businessDiagnostics)
154 r.checkMocks(file, filePath, current)159 r.checkMocks(file, filePath, current)
155 }160 }
156 }161 }
@@ -255,11 +260,12 @@ func (r runner) matchesExternal(pkg string) bool {
255 return false260 return false
256 }261 }
257 262
258-func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) {263+func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
259 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {264 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {
260- return265+ return false
261 }266 }
262 r.pass.Reportf(pos, rule+": "+format, args...)267 r.pass.Reportf(pos, rule+": "+format, args...)
268+ return true
263 }269 }
264 270
265 func (r runner) isExcluded(rule, filePath string) bool {271 func (r runner) isExcluded(rule, filePath string) bool {
M analyzer/analyzer_test.go +18 -3
@@ -14,10 +14,15 @@ func TestAnalyzer(t *testing.T) {
1414 patterns []string
1515 }{
1616 {
17- name: "boundary imports and heuristics",
17+ name: "boundary imports",
1818 patterns: []string{
1919 "example.com/project/internal/domain",
2020 "example.com/project/internal/application/usecase",
21+ },
22+ },
23+ {
24+ name: "heuristics",
25+ patterns: []string{
2126 "example.com/project/internal/infrastructure/http",
2227 },
2328 },
@@ -36,18 +41,28 @@ func TestAnalyzer(t *testing.T) {
3641 }
3742 }
3843
44+func testIntPtr(v int) *int { return &v }
45+
3946 func testConfig() *config.Config {
4047 cfg := &config.Config{
4148 Root: filepath.Join(analysistest.TestData(), "src/example.com/project"),
4249 Version: 1,
4350 Components: map[string]config.Component{
44- "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}},
51+ "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**", "internal/policy/**"}},
4552 "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}},
4653 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
4754 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
48- "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**"}},
55+ "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},
4956 },
5057 Rules: config.DefaultRuleSeverities(),
58+ Heuristics: config.Heuristics{
59+ BusinessLogicThreshold: testIntPtr(8),
60+ BusinessLogicMinStrongSignals: testIntPtr(2),
61+ BusinessLogicMinWeakSignals: testIntPtr(2),
62+ BusinessLogicMaxFunctionNodes: testIntPtr(2000),
63+ BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
64+ BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"},
65+ },
5166 ExternalTypes: config.ExternalTypes{
5267 FrameworkPackages: []string{"example.com/framework"},
5368 },
@@ -14,10 +14,15 @@ func TestAnalyzer(t *testing.T) {
14 patterns []string14 patterns []string
15 }{15 }{
16 {16 {
17- name: "boundary imports and heuristics",17+ name: "boundary imports",
18 patterns: []string{18 patterns: []string{
19 "example.com/project/internal/domain",19 "example.com/project/internal/domain",
20 "example.com/project/internal/application/usecase",20 "example.com/project/internal/application/usecase",
21+ },
22+ },
23+ {
24+ name: "heuristics",
25+ patterns: []string{
21 "example.com/project/internal/infrastructure/http",26 "example.com/project/internal/infrastructure/http",
22 },27 },
23 },28 },
@@ -36,18 +41,28 @@ func TestAnalyzer(t *testing.T) {
36 }41 }
37 }42 }
38 43
44+func testIntPtr(v int) *int { return &v }
45+
39 func testConfig() *config.Config {46 func testConfig() *config.Config {
40 cfg := &config.Config{47 cfg := &config.Config{
41 Root: filepath.Join(analysistest.TestData(), "src/example.com/project"),48 Root: filepath.Join(analysistest.TestData(), "src/example.com/project"),
42 Version: 1,49 Version: 1,
43 Components: map[string]config.Component{50 Components: map[string]config.Component{
44- "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**"}},51+ "domain": {Role: config.RoleCore, Paths: []string{"internal/domain/**", "internal/policy/**"}},
45 "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}},52 "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}},
46 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},53 "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}},
47 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},54 "http": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/http/**"}},
48- "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**"}},55+ "sql": {Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/sql/**", "internal/sqlbad/**"}},
49 },56 },
50 Rules: config.DefaultRuleSeverities(),57 Rules: config.DefaultRuleSeverities(),
58+ Heuristics: config.Heuristics{
59+ BusinessLogicThreshold: testIntPtr(8),
60+ BusinessLogicMinStrongSignals: testIntPtr(2),
61+ BusinessLogicMinWeakSignals: testIntPtr(2),
62+ BusinessLogicMaxFunctionNodes: testIntPtr(2000),
63+ BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
64+ BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"},
65+ },
51 ExternalTypes: config.ExternalTypes{66 ExternalTypes: config.ExternalTypes{
52 FrameworkPackages: []string{"example.com/framework"},67 FrameworkPackages: []string{"example.com/framework"},
53 },68 },
M analyzer/business_logic.go +241 -14
@@ -1,13 +1,21 @@
11 package analyzer
22
33 import (
4+ "fmt"
45 "go/ast"
6+ "go/token"
7+ "go/types"
58 "strings"
69
710 "github.com/bnema/hexcheck/config"
811 )
912
10-func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) {
13+type businessLogicSignal struct {
14+ kind string
15+ strong bool
16+}
17+
18+func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) {
1119 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
1220 return
1321 }
@@ -15,36 +23,255 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
1523 return
1624 }
1725
18- threshold := r.cfg.Heuristics.BusinessLogicThreshold
1926 for _, decl := range file.Decls {
2027 fn, ok := decl.(*ast.FuncDecl)
2128 if !ok || fn.Body == nil {
2229 continue
2330 }
31+ if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
32+ return
33+ }
34+ if r.isSuspiciousBusinessFunction(fn, filePath) {
35+ *packageDiagnostics++
36+ }
37+ }
38+}
39+
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)
43+ if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
44+ return false
45+ }
46+ 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))
49+ }
50+ return false
51+}
52+
53+type businessFunctionFacts struct {
54+ runner runner
55+ fn *ast.FuncDecl
56+
57+ nodeCount int
58+ branchCount int
59+ keyword bool
60+ domainTypeUse bool
61+ domainMutation bool
62+ domainMethodCall bool
63+ domainConstructorCall bool
64+ domainErrorReturn bool
65+ policyConstant bool
66+ usecaseCalls map[string]bool
67+}
68+
69+func (f *businessFunctionFacts) inspect(n ast.Node) bool {
70+ if n == nil {
71+ return true
72+ }
73+ f.nodeCount++
74+ if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
75+ return false
76+ }
77+
78+ if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
79+ f.domainTypeUse = true
80+ }
81+
82+ switch node := n.(type) {
83+ case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
84+ f.branchCount++
85+ case *ast.AssignStmt:
86+ for _, lhs := range node.Lhs {
87+ if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) {
88+ f.domainMutation = true
89+ }
90+ }
91+ case *ast.CallExpr:
92+ f.inspectCall(node)
93+ case *ast.BasicLit:
94+ if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
95+ f.policyConstant = true
96+ }
97+ case *ast.ReturnStmt:
98+ for _, result := range node.Results {
99+ if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) {
100+ f.domainErrorReturn = true
101+ }
102+ }
103+ }
104+ return true
105+}
24106
25- score := 0
26- for _, keyword := range r.cfg.Heuristics.BusinessKeywords {
27- if containsWord(fn.Name.Name, keyword) {
28- score += 2
107+func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
108+ calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
109+ if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
110+ f.domainConstructorCall = true
111+ }
112+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
113+ if f.exprUsesRole(sel.X, config.RoleCore) {
114+ f.domainMethodCall = true
115+ }
116+ if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
117+ if f.usecaseCalls == nil {
118+ f.usecaseCalls = map[string]bool{}
29119 }
120+ f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
121+ }
122+ }
123+ for _, arg := range call.Args {
124+ if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
125+ f.domainTypeUse = true
30126 }
127+ }
128+}
129+
130+func collaboratorKey(info *types.Info, expr ast.Expr) string {
131+ if ident, ok := expr.(*ast.Ident); ok {
132+ if obj := info.ObjectOf(ident); obj != nil {
133+ return obj.Pkg().Path() + "." + obj.Name()
134+ }
135+ }
136+ return fmt.Sprintf("%p", expr)
137+}
31138
32- ast.Inspect(fn.Body, func(n ast.Node) bool {
33- switch n.(type) {
34- case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
35- score++
139+func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
140+ strong := []businessLogicSignal{}
141+ weak := []businessLogicSignal{}
142+ if len(f.fn.Type.Params.List) > 0 {
143+ for _, field := range f.fn.Type.Params.List {
144+ if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
145+ f.domainTypeUse = true
36146 }
147+ }
148+ }
149+ for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
150+ if containsWord(f.fn.Name.Name, keyword) {
151+ f.keyword = true
152+ break
153+ }
154+ }
155+
156+ if f.domainMutation {
157+ strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true})
158+ }
159+ if f.domainMethodCall {
160+ strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true})
161+ }
162+ if f.keyword && len(f.usecaseCalls) >= 2 {
163+ strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
164+ }
165+ if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
166+ strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
167+ }
168+ if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) {
169+ strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
170+ }
171+
172+ if f.keyword {
173+ weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
174+ }
175+ if f.policyConstant {
176+ weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
177+ }
178+ if f.domainErrorReturn {
179+ weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
180+ }
181+ if len(f.usecaseCalls) >= 2 {
182+ weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
183+ }
184+ if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
185+ weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
186+ }
187+ return strong, weak
188+}
189+
190+func policyKeyword(name string) bool {
191+ for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
192+ if containsWord(name, word) {
193+ return true
194+ }
195+ }
196+ return false
197+}
198+
199+func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
200+ if expr == nil {
201+ return false
202+ }
203+ return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
204+}
205+
206+func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
207+ sel, ok := expr.(*ast.SelectorExpr)
208+ if !ok {
209+ return false
210+ }
211+ return f.exprUsesRole(sel.X, roles...)
212+}
213+
214+func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
215+ for _, role := range roles {
216+ if f.typeHasRole(t, role) {
37217 return true
38- })
218+ }
219+ }
220+ return false
221+}
39222
40- if score >= threshold {
41- r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has suspicious business-logic score %d", fn.Name.Name, score)
223+func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
224+ if t == nil {
225+ return false
226+ }
227+ switch tt := t.(type) {
228+ case *types.Pointer:
229+ return f.typeHasRole(tt.Elem(), role)
230+ case *types.Slice:
231+ return f.typeHasRole(tt.Elem(), role)
232+ case *types.Array:
233+ return f.typeHasRole(tt.Elem(), role)
234+ case *types.Map:
235+ return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
236+ case *types.Signature:
237+ return f.typeHasRole(tt.Results(), role)
238+ case *types.Tuple:
239+ for i := 0; i < tt.Len(); i++ {
240+ if f.typeHasRole(tt.At(i).Type(), role) {
241+ return true
242+ }
243+ }
244+ return false
245+ case *types.Named:
246+ obj := tt.Obj()
247+ if obj == nil || obj.Pkg() == nil {
248+ return false
42249 }
250+ match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
251+ if ok && match.Role == role {
252+ return true
253+ }
254+ return f.typeHasRole(tt.Underlying(), role)
255+ default:
256+ return false
257+ }
258+}
259+
260+func exprFromNode(n ast.Node) ast.Expr {
261+ expr, _ := n.(ast.Expr)
262+ return expr
263+}
264+
265+func signalKinds(signals []businessLogicSignal) []string {
266+ out := make([]string, 0, len(signals))
267+ for _, signal := range signals {
268+ out = append(out, signal.kind)
43269 }
270+ return out
44271 }
45272
46273 func containsWord(name, word string) bool {
47- if word == "" {
274+ if word == "" || len(word) > len(name) {
48275 return false
49276 }
50277 for i := 0; i <= len(name)-len(word); i++ {
@@ -1,13 +1,21 @@
1 package analyzer1 package analyzer
2 2
3 import (3 import (
4+ "fmt"
4 "go/ast"5 "go/ast"
6+ "go/token"
7+ "go/types"
5 "strings"8 "strings"
6 9
7 "github.com/bnema/hexcheck/config"10 "github.com/bnema/hexcheck/config"
8 )11 )
9 12
10-func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) {13+type businessLogicSignal struct {
14+ kind string
15+ strong bool
16+}
17+
18+func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) {
11 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {19 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
12 return20 return
13 }21 }
@@ -15,36 +23,255 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
15 return23 return
16 }24 }
17 25
18- threshold := r.cfg.Heuristics.BusinessLogicThreshold
19 for _, decl := range file.Decls {26 for _, decl := range file.Decls {
20 fn, ok := decl.(*ast.FuncDecl)27 fn, ok := decl.(*ast.FuncDecl)
21 if !ok || fn.Body == nil {28 if !ok || fn.Body == nil {
22 continue29 continue
23 }30 }
31+ if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage {
32+ return
33+ }
34+ if r.isSuspiciousBusinessFunction(fn, filePath) {
35+ *packageDiagnostics++
36+ }
37+ }
38+}
39+
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)
43+ if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
44+ return false
45+ }
46+ 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))
49+ }
50+ return false
51+}
52+
53+type businessFunctionFacts struct {
54+ runner runner
55+ fn *ast.FuncDecl
56+
57+ nodeCount int
58+ branchCount int
59+ keyword bool
60+ domainTypeUse bool
61+ domainMutation bool
62+ domainMethodCall bool
63+ domainConstructorCall bool
64+ domainErrorReturn bool
65+ policyConstant bool
66+ usecaseCalls map[string]bool
67+}
68+
69+func (f *businessFunctionFacts) inspect(n ast.Node) bool {
70+ if n == nil {
71+ return true
72+ }
73+ f.nodeCount++
74+ if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
75+ return false
76+ }
77+
78+ if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
79+ f.domainTypeUse = true
80+ }
81+
82+ switch node := n.(type) {
83+ case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:
84+ f.branchCount++
85+ case *ast.AssignStmt:
86+ for _, lhs := range node.Lhs {
87+ if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) {
88+ f.domainMutation = true
89+ }
90+ }
91+ case *ast.CallExpr:
92+ f.inspectCall(node)
93+ case *ast.BasicLit:
94+ if node.Kind == token.INT || node.Kind == token.FLOAT || node.Kind == token.STRING {
95+ f.policyConstant = true
96+ }
97+ case *ast.ReturnStmt:
98+ for _, result := range node.Results {
99+ if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) {
100+ f.domainErrorReturn = true
101+ }
102+ }
103+ }
104+ return true
105+}
24 106
25- score := 0107+func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
26- for _, keyword := range r.cfg.Heuristics.BusinessKeywords {108+ calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
27- if containsWord(fn.Name.Name, keyword) {109+ if f.typeHasRole(calleeType, config.RoleCore) || f.typeHasRole(calleeType, config.RoleUsecase) {
28- score += 2110+ f.domainConstructorCall = true
111+ }
112+ if sel, ok := call.Fun.(*ast.SelectorExpr); ok {
113+ if f.exprUsesRole(sel.X, config.RoleCore) {
114+ f.domainMethodCall = true
115+ }
116+ if f.exprUsesRole(sel.X, config.RolePorts) || f.exprUsesRole(sel.X, config.RoleUsecase) {
117+ if f.usecaseCalls == nil {
118+ f.usecaseCalls = map[string]bool{}
29 }119 }
120+ f.usecaseCalls[collaboratorKey(f.runner.pass.TypesInfo, sel.X)] = true
121+ }
122+ }
123+ for _, arg := range call.Args {
124+ if f.exprUsesRole(arg, config.RoleCore) || f.exprUsesRole(arg, config.RoleUsecase) {
125+ f.domainTypeUse = true
30 }126 }
127+ }
128+}
129+
130+func collaboratorKey(info *types.Info, expr ast.Expr) string {
131+ if ident, ok := expr.(*ast.Ident); ok {
132+ if obj := info.ObjectOf(ident); obj != nil {
133+ return obj.Pkg().Path() + "." + obj.Name()
134+ }
135+ }
136+ return fmt.Sprintf("%p", expr)
137+}
31 138
32- ast.Inspect(fn.Body, func(n ast.Node) bool {139+func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLogicSignal) {
33- switch n.(type) {140+ strong := []businessLogicSignal{}
34- case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt:141+ weak := []businessLogicSignal{}
35- score++142+ if len(f.fn.Type.Params.List) > 0 {
143+ for _, field := range f.fn.Type.Params.List {
144+ if f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(field.Type), config.RoleCore, config.RoleUsecase) {
145+ f.domainTypeUse = true
36 }146 }
147+ }
148+ }
149+ for _, keyword := range f.runner.cfg.Heuristics.BusinessKeywords {
150+ if containsWord(f.fn.Name.Name, keyword) {
151+ f.keyword = true
152+ break
153+ }
154+ }
155+
156+ if f.domainMutation {
157+ strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true})
158+ }
159+ if f.domainMethodCall {
160+ strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true})
161+ }
162+ if f.keyword && len(f.usecaseCalls) >= 2 {
163+ strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
164+ }
165+ if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
166+ strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
167+ }
168+ if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) {
169+ strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
170+ }
171+
172+ if f.keyword {
173+ weak = append(weak, businessLogicSignal{kind: "business keyword", strong: false})
174+ }
175+ if f.policyConstant {
176+ weak = append(weak, businessLogicSignal{kind: "policy constants", strong: false})
177+ }
178+ if f.domainErrorReturn {
179+ weak = append(weak, businessLogicSignal{kind: "domain result return", strong: false})
180+ }
181+ if len(f.usecaseCalls) >= 2 {
182+ weak = append(weak, businessLogicSignal{kind: "multiple port/usecase collaborators", strong: false})
183+ }
184+ if f.branchCount >= *f.runner.cfg.Heuristics.BusinessLogicThreshold {
185+ weak = append(weak, businessLogicSignal{kind: fmt.Sprintf("branch count %d", f.branchCount), strong: false})
186+ }
187+ return strong, weak
188+}
189+
190+func policyKeyword(name string) bool {
191+ for _, word := range []string{"Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Performance", "Selected"} {
192+ if containsWord(name, word) {
193+ return true
194+ }
195+ }
196+ return false
197+}
198+
199+func (f *businessFunctionFacts) exprUsesRole(expr ast.Expr, roles ...config.Role) bool {
200+ if expr == nil {
201+ return false
202+ }
203+ return f.typeHasAnyRole(f.runner.pass.TypesInfo.TypeOf(expr), roles...)
204+}
205+
206+func (f *businessFunctionFacts) selectorReceiverUsesRole(expr ast.Expr, roles ...config.Role) bool {
207+ sel, ok := expr.(*ast.SelectorExpr)
208+ if !ok {
209+ return false
210+ }
211+ return f.exprUsesRole(sel.X, roles...)
212+}
213+
214+func (f *businessFunctionFacts) typeHasAnyRole(t types.Type, roles ...config.Role) bool {
215+ for _, role := range roles {
216+ if f.typeHasRole(t, role) {
37 return true217 return true
38- })218+ }
219+ }
220+ return false
221+}
39 222
40- if score >= threshold {223+func (f *businessFunctionFacts) typeHasRole(t types.Type, role config.Role) bool {
41- r.report(fn.Name.Pos(), "suspicious-business-logic-in-adapter", filePath, "adapter function %s has suspicious business-logic score %d", fn.Name.Name, score)224+ if t == nil {
225+ return false
226+ }
227+ switch tt := t.(type) {
228+ case *types.Pointer:
229+ return f.typeHasRole(tt.Elem(), role)
230+ case *types.Slice:
231+ return f.typeHasRole(tt.Elem(), role)
232+ case *types.Array:
233+ return f.typeHasRole(tt.Elem(), role)
234+ case *types.Map:
235+ return f.typeHasRole(tt.Key(), role) || f.typeHasRole(tt.Elem(), role)
236+ case *types.Signature:
237+ return f.typeHasRole(tt.Results(), role)
238+ case *types.Tuple:
239+ for i := 0; i < tt.Len(); i++ {
240+ if f.typeHasRole(tt.At(i).Type(), role) {
241+ return true
242+ }
243+ }
244+ return false
245+ case *types.Named:
246+ obj := tt.Obj()
247+ if obj == nil || obj.Pkg() == nil {
248+ return false
42 }249 }
250+ match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path()))
251+ if ok && match.Role == role {
252+ return true
253+ }
254+ return f.typeHasRole(tt.Underlying(), role)
255+ default:
256+ return false
257+ }
258+}
259+
260+func exprFromNode(n ast.Node) ast.Expr {
261+ expr, _ := n.(ast.Expr)
262+ return expr
263+}
264+
265+func signalKinds(signals []businessLogicSignal) []string {
266+ out := make([]string, 0, len(signals))
267+ for _, signal := range signals {
268+ out = append(out, signal.kind)
43 }269 }
270+ return out
44 }271 }
45 272
46 func containsWord(name, word string) bool {273 func containsWord(name, word string) bool {
47- if word == "" {274+ if word == "" || len(word) > len(name) {
48 return false275 return false
49 }276 }
50 for i := 0; i <= len(name)-len(word); i++ {277 for i := 0; i <= len(name)-len(word); i++ {
M analyzer/config_clone.go +13 -0
@@ -19,6 +19,11 @@ func cloneConfig(in *config.Config) *config.Config {
1919 out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...)
2020 out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...)
2121 out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...)
22+ out.Heuristics.BusinessLogicThreshold = cloneInt(in.Heuristics.BusinessLogicThreshold)
23+ out.Heuristics.BusinessLogicMinStrongSignals = cloneInt(in.Heuristics.BusinessLogicMinStrongSignals)
24+ out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals)
25+ out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes)
26+ out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage)
2227 if in.Heuristics.ExcludeTestFiles != nil {
2328 value := *in.Heuristics.ExcludeTestFiles
2429 out.Heuristics.ExcludeTestFiles = &value
@@ -33,3 +38,11 @@ func cloneConfig(in *config.Config) *config.Config {
3338 out.Allow = append([]config.Allow(nil), in.Allow...)
3439 return &out
3540 }
41+
42+func cloneInt(in *int) *int {
43+ if in == nil {
44+ return nil
45+ }
46+ out := *in
47+ return &out
48+}
@@ -19,6 +19,11 @@ func cloneConfig(in *config.Config) *config.Config {
19 out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...)19 out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...)
20 out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...)20 out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...)
21 out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...)21 out.Heuristics.BusinessKeywords = append([]string(nil), in.Heuristics.BusinessKeywords...)
22+ out.Heuristics.BusinessLogicThreshold = cloneInt(in.Heuristics.BusinessLogicThreshold)
23+ out.Heuristics.BusinessLogicMinStrongSignals = cloneInt(in.Heuristics.BusinessLogicMinStrongSignals)
24+ out.Heuristics.BusinessLogicMinWeakSignals = cloneInt(in.Heuristics.BusinessLogicMinWeakSignals)
25+ out.Heuristics.BusinessLogicMaxFunctionNodes = cloneInt(in.Heuristics.BusinessLogicMaxFunctionNodes)
26+ out.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = cloneInt(in.Heuristics.BusinessLogicMaxDiagnosticsPerPackage)
22 if in.Heuristics.ExcludeTestFiles != nil {27 if in.Heuristics.ExcludeTestFiles != nil {
23 value := *in.Heuristics.ExcludeTestFiles28 value := *in.Heuristics.ExcludeTestFiles
24 out.Heuristics.ExcludeTestFiles = &value29 out.Heuristics.ExcludeTestFiles = &value
@@ -33,3 +38,11 @@ func cloneConfig(in *config.Config) *config.Config {
33 out.Allow = append([]config.Allow(nil), in.Allow...)38 out.Allow = append([]config.Allow(nil), in.Allow...)
34 return &out39 return &out
35 }40 }
41+
42+func cloneInt(in *int) *int {
43+ if in == nil {
44+ return nil
45+ }
46+ out := *in
47+ return &out
48+}
A analyzer/config_clone_test.go +35 -0
@@ -0,0 +1,35 @@
1+package analyzer
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/hexcheck/config"
7+)
8+
9+func TestResolveConfigDefaultsPartialInMemoryConfig(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ cfg *config.Config
13+ }{
14+ {
15+ name: "partial config",
16+ cfg: &config.Config{Version: 1, Components: map[string]config.Component{
17+ "adapters": {Role: config.RoleAdapter, Paths: []string{"internal/**"}},
18+ }},
19+ },
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ cfg, _, err := resolveConfig(Options{Config: tt.cfg}, "", "")
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if cfg.Heuristics.BusinessLogicThreshold == nil || cfg.Heuristics.BusinessLogicMaxFunctionNodes == nil {
28+ t.Fatal("heuristic defaults were not applied")
29+ }
30+ if tt.cfg.Heuristics.BusinessLogicThreshold != nil {
31+ t.Fatal("input config was mutated")
32+ }
33+ })
34+ }
35+}
@@ -0,0 +1,35 @@
1+package analyzer
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/hexcheck/config"
7+)
8+
9+func TestResolveConfigDefaultsPartialInMemoryConfig(t *testing.T) {
10+ tests := []struct {
11+ name string
12+ cfg *config.Config
13+ }{
14+ {
15+ name: "partial config",
16+ cfg: &config.Config{Version: 1, Components: map[string]config.Component{
17+ "adapters": {Role: config.RoleAdapter, Paths: []string{"internal/**"}},
18+ }},
19+ },
20+ }
21+ for _, tt := range tests {
22+ t.Run(tt.name, func(t *testing.T) {
23+ cfg, _, err := resolveConfig(Options{Config: tt.cfg}, "", "")
24+ if err != nil {
25+ t.Fatal(err)
26+ }
27+ if cfg.Heuristics.BusinessLogicThreshold == nil || cfg.Heuristics.BusinessLogicMaxFunctionNodes == nil {
28+ t.Fatal("heuristic defaults were not applied")
29+ }
30+ if tt.cfg.Heuristics.BusinessLogicThreshold != nil {
31+ t.Fatal("input config was mutated")
32+ }
33+ })
34+ }
35+}
A analyzer/testdata/src/example.com/project/internal/application/port/mocks/more_mocks.go +4 -0
@@ -0,0 +1,4 @@
1+package mocks
2+
3+type MockInventory struct{}
4+type MockPayments struct{}
@@ -0,0 +1,4 @@
1+package mocks
2+
3+type MockInventory struct{}
4+type MockPayments struct{}
A analyzer/testdata/src/example.com/project/internal/application/port/order.go +9 -0
@@ -0,0 +1,9 @@
1+package port
2+
3+type Inventory interface {
4+ Reserve() error
5+}
6+
7+type Payments interface {
8+ Charge() error
9+}
@@ -0,0 +1,9 @@
1+package port
2+
3+type Inventory interface {
4+ Reserve() error
5+}
6+
7+type Payments interface {
8+ Charge() error
9+}
M analyzer/testdata/src/example.com/project/internal/domain/domain.go +1 -1
@@ -1,5 +1,5 @@
11 package domain
22
3-import _ "example.com/project/internal/infrastructure/http" // want "no-adapter-imports-in-core"
3+import _ "example.com/project/internal/sqlbad" // want "no-adapter-imports-in-core"
44
55 type User struct{ Name string }
@@ -1,5 +1,5 @@
1 package domain1 package domain
2 2
3-import _ "example.com/project/internal/infrastructure/http" // want "no-adapter-imports-in-core"3+import _ "example.com/project/internal/sqlbad" // want "no-adapter-imports-in-core"
4 4
5 type User struct{ Name string }5 type User struct{ Name string }
A analyzer/testdata/src/example.com/project/internal/domain/policy.go +5 -0
@@ -0,0 +1,5 @@
1+package domain
2+
3+type PolicyError struct{}
4+
5+func (PolicyError) Error() string { return "policy" }
@@ -0,0 +1,5 @@
1+package domain
2+
3+type PolicyError struct{}
4+
5+func (PolicyError) Error() string { return "policy" }
R analyzer/testdata/src/example.com/project/internal/infrastructure/http/generated_templ.go → analyzer/testdata/src/example.com/project/internal/infrastructure/generatedhtml/generated_templ.go +1 -1 renamed, 95% similar
@@ -1,5 +1,5 @@
11 // Code generated by templ - DO NOT EDIT.
2-package http
2+package httpgen
33
44 func ValidateGenerated(value int) bool {
55 if value == 1 {
@@ -1,5 +1,5 @@
1 // Code generated by templ - DO NOT EDIT.1 // Code generated by templ - DO NOT EDIT.
2-package http2+package httpgen
3 3
4 func ValidateGenerated(value int) bool {4 func ValidateGenerated(value int) bool {
5 if value == 1 {5 if value == 1 {
M analyzer/testdata/src/example.com/project/internal/infrastructure/http/logic.go +28 -12
@@ -1,23 +1,39 @@
11 package http
22
3-func ValidateOrder(total int, active bool, region string) bool { // want "suspicious-business-logic-in-adapter"
4- if total <= 0 {
5- return false
3+import "example.com/project/internal/domain"
4+
5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"
6+ if order.Name == "" {
7+ return domain.PolicyError{}
68 }
7- if !active {
9+ order.Name = "approved"
10+ return nil
11+}
12+
13+func RunTransportRetries(attempts int) bool {
14+ if attempts > 10 {
815 return false
916 }
10- if region == "" {
11- return false
17+ if attempts == 1 {
18+ return true
1219 }
13- if total > 1000 {
14- return region == "trusted"
20+ if attempts == 2 {
21+ return true
1522 }
16- if region == "blocked" {
17- return false
23+ if attempts == 3 {
24+ return true
25+ }
26+ if attempts == 4 {
27+ return true
28+ }
29+ if attempts == 5 {
30+ return true
31+ }
32+ if attempts == 6 {
33+ return true
1834 }
19- if total == 42 {
35+ if attempts == 7 {
2036 return true
2137 }
22- return true
38+ return false
2339 }
@@ -1,23 +1,39 @@
1 package http1 package http
2 2
3-func ValidateOrder(total int, active bool, region string) bool { // want "suspicious-business-logic-in-adapter"3+import "example.com/project/internal/domain"
4- if total <= 0 {4+
5- return false5+func ValidateOrder(order *domain.User) error { // want "suspicious-business-logic-in-adapter"
6+ if order.Name == "" {
7+ return domain.PolicyError{}
6 }8 }
7- if !active {9+ order.Name = "approved"
10+ return nil
11+}
12+
13+func RunTransportRetries(attempts int) bool {
14+ if attempts > 10 {
8 return false15 return false
9 }16 }
10- if region == "" {17+ if attempts == 1 {
11- return false18+ return true
12 }19 }
13- if total > 1000 {20+ if attempts == 2 {
14- return region == "trusted"21+ return true
15 }22 }
16- if region == "blocked" {23+ if attempts == 3 {
17- return false24+ return true
25+ }
26+ if attempts == 4 {
27+ return true
28+ }
29+ if attempts == 5 {
30+ return true
31+ }
32+ if attempts == 6 {
33+ return true
18 }34 }
19- if total == 42 {35+ if attempts == 7 {
20 return true36 return true
21 }37 }
22- return true38+ return false
23 }39 }
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/orchestration.go +28 -0
@@ -0,0 +1,28 @@
1+package http
2+
3+import "example.com/project/internal/application/port"
4+
5+type orderHandler struct {
6+ inventory port.Inventory
7+ payments port.Payments
8+}
9+
10+func (h orderHandler) ValidateOrder() error { // want "suspicious-business-logic-in-adapter"
11+ if err := h.inventory.Reserve(); err != nil {
12+ return err
13+ }
14+ if err := h.payments.Charge(); err != nil {
15+ return err
16+ }
17+ return nil
18+}
19+
20+func (h orderHandler) RunOrder() error {
21+ if err := h.inventory.Reserve(); err != nil {
22+ return err
23+ }
24+ if err := h.payments.Charge(); err != nil {
25+ return err
26+ }
27+ return nil
28+}
@@ -0,0 +1,28 @@
1+package http
2+
3+import "example.com/project/internal/application/port"
4+
5+type orderHandler struct {
6+ inventory port.Inventory
7+ payments port.Payments
8+}
9+
10+func (h orderHandler) ValidateOrder() error { // want "suspicious-business-logic-in-adapter"
11+ if err := h.inventory.Reserve(); err != nil {
12+ return err
13+ }
14+ if err := h.payments.Charge(); err != nil {
15+ return err
16+ }
17+ return nil
18+}
19+
20+func (h orderHandler) RunOrder() error {
21+ if err := h.inventory.Reserve(); err != nil {
22+ return err
23+ }
24+ if err := h.payments.Charge(); err != nil {
25+ return err
26+ }
27+ return nil
28+}
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/policy.go +31 -0
@@ -0,0 +1,31 @@
1+package http
2+
3+func DetectChanges(oldKeys, newKeys []string) string { // want "suspicious-business-logic-in-adapter"
4+ if len(oldKeys) == 0 {
5+ return "added"
6+ }
7+ if len(newKeys) == 0 {
8+ return "removed"
9+ }
10+ if len(oldKeys) > len(newKeys) {
11+ return "renamed"
12+ }
13+ return "unchanged"
14+}
15+
16+func computeHistoryCandidateScore(hostMatch, recent bool, visits int) int { // want "suspicious-business-logic-in-adapter"
17+ score := 0
18+ if hostMatch {
19+ score += 50
20+ }
21+ if recent {
22+ score += 25
23+ }
24+ if visits > 10 {
25+ score += 5
26+ }
27+ if score > 100 {
28+ return 100
29+ }
30+ return score
31+}
@@ -0,0 +1,31 @@
1+package http
2+
3+func DetectChanges(oldKeys, newKeys []string) string { // want "suspicious-business-logic-in-adapter"
4+ if len(oldKeys) == 0 {
5+ return "added"
6+ }
7+ if len(newKeys) == 0 {
8+ return "removed"
9+ }
10+ if len(oldKeys) > len(newKeys) {
11+ return "renamed"
12+ }
13+ return "unchanged"
14+}
15+
16+func computeHistoryCandidateScore(hostMatch, recent bool, visits int) int { // want "suspicious-business-logic-in-adapter"
17+ score := 0
18+ if hostMatch {
19+ score += 50
20+ }
21+ if recent {
22+ score += 25
23+ }
24+ if visits > 10 {
25+ score += 5
26+ }
27+ if score > 100 {
28+ return 100
29+ }
30+ return score
31+}
A analyzer/testdata/src/example.com/project/internal/policy/policy.go +12 -0
@@ -0,0 +1,12 @@
1+package policy
2+
3+type Order struct {
4+ Total int
5+ Status string
6+}
7+
8+func (o *Order) CanApprove() bool { return o.Total > 0 }
9+
10+type PolicyError struct{}
11+
12+func (PolicyError) Error() string { return "policy" }
@@ -0,0 +1,12 @@
1+package policy
2+
3+type Order struct {
4+ Total int
5+ Status string
6+}
7+
8+func (o *Order) CanApprove() bool { return o.Total > 0 }
9+
10+type PolicyError struct{}
11+
12+func (PolicyError) Error() string { return "policy" }
A analyzer/testdata/src/example.com/project/internal/sqlbad/sqlbad.go +3 -0
@@ -0,0 +1,3 @@
1+package sqlbad
2+
3+type Row struct{ ID string }
@@ -0,0 +1,3 @@
1+package sqlbad
2+
3+type Row struct{ ID string }
M config/config.go +63 -6
@@ -53,9 +53,13 @@ type ExternalTypes struct {
5353 }
5454
5555 type Heuristics struct {
56- BusinessLogicThreshold int `yaml:"businessLogicThreshold"`
57- BusinessKeywords []string `yaml:"businessKeywords"`
58- ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
56+ BusinessLogicThreshold *int `yaml:"businessLogicThreshold"`
57+ BusinessLogicMinStrongSignals *int `yaml:"businessLogicMinStrongSignals"`
58+ BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"`
59+ BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"`
60+ BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"`
61+ BusinessKeywords []string `yaml:"businessKeywords"`
62+ ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
5963 }
6064
6165 type Mocking struct {
@@ -87,6 +91,9 @@ func Load(path string) (*Config, error) {
8791 if err := yaml.Unmarshal(data, &cfg); err != nil {
8892 return nil, err
8993 }
94+ if hasExplicitVersion(data) && cfg.Version == 0 {
95+ return nil, errors.New("version: unsupported value 0")
96+ }
9097 root, err := filepath.Abs(filepath.Dir(path))
9198 if err != nil {
9299 return nil, err
@@ -105,6 +112,10 @@ func Default() *Config {
105112 return cfg
106113 }
107114
115+func (c *Config) ApplyDefaults() {
116+ c.applyDefaults()
117+}
118+
108119 func (c *Config) applyDefaults() {
109120 if c.Version == 0 {
110121 c.Version = 1
@@ -117,21 +128,52 @@ func (c *Config) applyDefaults() {
117128 c.Rules[rule] = sev
118129 }
119130 }
120- if c.Heuristics.BusinessLogicThreshold == 0 {
121- c.Heuristics.BusinessLogicThreshold = 8
131+ if c.Heuristics.BusinessLogicThreshold == nil {
132+ c.Heuristics.BusinessLogicThreshold = intPtr(8)
133+ }
134+ if c.Heuristics.BusinessLogicMinStrongSignals == nil {
135+ c.Heuristics.BusinessLogicMinStrongSignals = intPtr(2)
136+ }
137+ if c.Heuristics.BusinessLogicMinWeakSignals == nil {
138+ c.Heuristics.BusinessLogicMinWeakSignals = intPtr(2)
139+ }
140+ if c.Heuristics.BusinessLogicMaxFunctionNodes == nil {
141+ c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(2000)
142+ }
143+ if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage == nil {
144+ c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(10)
122145 }
123146 if c.Heuristics.ExcludeTestFiles == nil {
124147 defaultExcludeTestFiles := true
125148 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles
126149 }
127150 if len(c.Heuristics.BusinessKeywords) == 0 {
128- c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"}
151+ c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}
129152 }
130153 if len(c.Mocking.GeneratedMockNamePatterns) == 0 {
131154 c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
132155 }
133156 }
134157
158+func intPtr(v int) *int { return &v }
159+
160+func hasExplicitVersion(data []byte) bool {
161+ var node yaml.Node
162+ if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 {
163+ return false
164+ }
165+ mapping := node.Content[0]
166+ if mapping.Kind != yaml.MappingNode {
167+ return false
168+ }
169+ for i := 0; i+1 < len(mapping.Content); i += 2 {
170+ if mapping.Content[i].Value == "version" {
171+ return true
172+ }
173+ }
174+ return false
175+}
176+
135177 func DefaultRuleSeverities() map[string]Severity {
136178 return map[string]Severity{
137179 "no-adapter-imports-in-core": SeverityError,
@@ -163,6 +205,21 @@ func (c *Config) Validate() error {
163205 return fmt.Errorf("components.%s.paths: must not be empty", name)
164206 }
165207 }
208+ if c.Heuristics.BusinessLogicThreshold != nil && *c.Heuristics.BusinessLogicThreshold < 0 {
209+ return errors.New("heuristics.businessLogicThreshold: must be >= 0")
210+ }
211+ if c.Heuristics.BusinessLogicMinStrongSignals != nil && *c.Heuristics.BusinessLogicMinStrongSignals < 0 {
212+ return errors.New("heuristics.businessLogicMinStrongSignals: must be >= 0")
213+ }
214+ if c.Heuristics.BusinessLogicMinWeakSignals != nil && *c.Heuristics.BusinessLogicMinWeakSignals < 0 {
215+ return errors.New("heuristics.businessLogicMinWeakSignals: must be >= 0")
216+ }
217+ if c.Heuristics.BusinessLogicMaxFunctionNodes != nil && *c.Heuristics.BusinessLogicMaxFunctionNodes < 1 {
218+ return errors.New("heuristics.businessLogicMaxFunctionNodes: must be >= 1")
219+ }
220+ if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 {
221+ return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1")
222+ }
166223 for rule, severity := range c.Rules {
167224 switch severity {
168225 case SeverityOff, SeverityWarn, SeverityError:
@@ -53,9 +53,13 @@ type ExternalTypes struct {
53 }53 }
54 54
55 type Heuristics struct {55 type Heuristics struct {
56- BusinessLogicThreshold int `yaml:"businessLogicThreshold"`56+ BusinessLogicThreshold *int `yaml:"businessLogicThreshold"`
57- BusinessKeywords []string `yaml:"businessKeywords"`57+ BusinessLogicMinStrongSignals *int `yaml:"businessLogicMinStrongSignals"`
58- ExcludeTestFiles *bool `yaml:"excludeTestFiles"`58+ BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"`
59+ BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"`
60+ BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"`
61+ BusinessKeywords []string `yaml:"businessKeywords"`
62+ ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
59 }63 }
60 64
61 type Mocking struct {65 type Mocking struct {
@@ -87,6 +91,9 @@ func Load(path string) (*Config, error) {
87 if err := yaml.Unmarshal(data, &cfg); err != nil {91 if err := yaml.Unmarshal(data, &cfg); err != nil {
88 return nil, err92 return nil, err
89 }93 }
94+ if hasExplicitVersion(data) && cfg.Version == 0 {
95+ return nil, errors.New("version: unsupported value 0")
96+ }
90 root, err := filepath.Abs(filepath.Dir(path))97 root, err := filepath.Abs(filepath.Dir(path))
91 if err != nil {98 if err != nil {
92 return nil, err99 return nil, err
@@ -105,6 +112,10 @@ func Default() *Config {
105 return cfg112 return cfg
106 }113 }
107 114
115+func (c *Config) ApplyDefaults() {
116+ c.applyDefaults()
117+}
118+
108 func (c *Config) applyDefaults() {119 func (c *Config) applyDefaults() {
109 if c.Version == 0 {120 if c.Version == 0 {
110 c.Version = 1121 c.Version = 1
@@ -117,21 +128,52 @@ func (c *Config) applyDefaults() {
117 c.Rules[rule] = sev128 c.Rules[rule] = sev
118 }129 }
119 }130 }
120- if c.Heuristics.BusinessLogicThreshold == 0 {131+ if c.Heuristics.BusinessLogicThreshold == nil {
121- c.Heuristics.BusinessLogicThreshold = 8132+ c.Heuristics.BusinessLogicThreshold = intPtr(8)
133+ }
134+ if c.Heuristics.BusinessLogicMinStrongSignals == nil {
135+ c.Heuristics.BusinessLogicMinStrongSignals = intPtr(2)
136+ }
137+ if c.Heuristics.BusinessLogicMinWeakSignals == nil {
138+ c.Heuristics.BusinessLogicMinWeakSignals = intPtr(2)
139+ }
140+ if c.Heuristics.BusinessLogicMaxFunctionNodes == nil {
141+ c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(2000)
142+ }
143+ if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage == nil {
144+ c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(10)
122 }145 }
123 if c.Heuristics.ExcludeTestFiles == nil {146 if c.Heuristics.ExcludeTestFiles == nil {
124 defaultExcludeTestFiles := true147 defaultExcludeTestFiles := true
125 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles148 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles
126 }149 }
127 if len(c.Heuristics.BusinessKeywords) == 0 {150 if len(c.Heuristics.BusinessKeywords) == 0 {
128- c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can"}151+ c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}
129 }152 }
130 if len(c.Mocking.GeneratedMockNamePatterns) == 0 {153 if len(c.Mocking.GeneratedMockNamePatterns) == 0 {
131 c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}154 c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
132 }155 }
133 }156 }
134 157
158+func intPtr(v int) *int { return &v }
159+
160+func hasExplicitVersion(data []byte) bool {
161+ var node yaml.Node
162+ if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 {
163+ return false
164+ }
165+ mapping := node.Content[0]
166+ if mapping.Kind != yaml.MappingNode {
167+ return false
168+ }
169+ for i := 0; i+1 < len(mapping.Content); i += 2 {
170+ if mapping.Content[i].Value == "version" {
171+ return true
172+ }
173+ }
174+ return false
175+}
176+
135 func DefaultRuleSeverities() map[string]Severity {177 func DefaultRuleSeverities() map[string]Severity {
136 return map[string]Severity{178 return map[string]Severity{
137 "no-adapter-imports-in-core": SeverityError,179 "no-adapter-imports-in-core": SeverityError,
@@ -163,6 +205,21 @@ func (c *Config) Validate() error {
163 return fmt.Errorf("components.%s.paths: must not be empty", name)205 return fmt.Errorf("components.%s.paths: must not be empty", name)
164 }206 }
165 }207 }
208+ if c.Heuristics.BusinessLogicThreshold != nil && *c.Heuristics.BusinessLogicThreshold < 0 {
209+ return errors.New("heuristics.businessLogicThreshold: must be >= 0")
210+ }
211+ if c.Heuristics.BusinessLogicMinStrongSignals != nil && *c.Heuristics.BusinessLogicMinStrongSignals < 0 {
212+ return errors.New("heuristics.businessLogicMinStrongSignals: must be >= 0")
213+ }
214+ if c.Heuristics.BusinessLogicMinWeakSignals != nil && *c.Heuristics.BusinessLogicMinWeakSignals < 0 {
215+ return errors.New("heuristics.businessLogicMinWeakSignals: must be >= 0")
216+ }
217+ if c.Heuristics.BusinessLogicMaxFunctionNodes != nil && *c.Heuristics.BusinessLogicMaxFunctionNodes < 1 {
218+ return errors.New("heuristics.businessLogicMaxFunctionNodes: must be >= 1")
219+ }
220+ if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 {
221+ return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1")
222+ }
166 for rule, severity := range c.Rules {223 for rule, severity := range c.Rules {
167 switch severity {224 switch severity {
168 case SeverityOff, SeverityWarn, SeverityError:225 case SeverityOff, SeverityWarn, SeverityError:
M config/config_test.go +51 -0
@@ -82,6 +82,34 @@ components:
8282 }
8383 }
8484
85+func TestLoadRejectsExplicitZeroVersion(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ yaml string
89+ }{
90+ {name: "zero", yaml: "version: 0\n"},
91+ {name: "positive zero", yaml: "version: +0\n"},
92+ {name: "double zero", yaml: "version: 00\n"},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ dir := t.TempDir()
97+ path := filepath.Join(dir, ".hexcheck.yaml")
98+ data := []byte(tt.yaml + `components:
99+ adapters:
100+ paths: [internal/infrastructure/**]
101+ role: adapter
102+`)
103+ if err := os.WriteFile(path, data, 0o600); err != nil {
104+ t.Fatal(err)
105+ }
106+ if _, err := Load(path); err == nil {
107+ t.Fatal("expected explicit zero version to be rejected")
108+ }
109+ })
110+ }
111+}
112+
85113 func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) {
86114 dir := t.TempDir()
87115 path := filepath.Join(dir, ".hexcheck.yaml")
@@ -108,6 +136,29 @@ heuristics:
108136 }
109137 }
110138
139+func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) {
140+ tests := []struct {
141+ name string
142+ mutate func(*Config)
143+ }{
144+ {name: "negative threshold", mutate: func(c *Config) { c.Heuristics.BusinessLogicThreshold = intPtr(-1) }},
145+ {name: "negative strong", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinStrongSignals = 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) }},
148+ {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }},
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ cfg := &Config{Version: 1, Components: map[string]Component{"adapters": {Role: RoleAdapter, Paths: []string{"internal/**"}}}}
153+ cfg.applyDefaults()
154+ tt.mutate(cfg)
155+ if err := cfg.Validate(); err == nil {
156+ t.Fatal("expected validation error")
157+ }
158+ })
159+ }
160+}
161+
111162 func TestComponentForPathPrecedence(t *testing.T) {
112163 cfg := &Config{Components: map[string]Component{
113164 "all": {Role: RoleCore, Paths: []string{"internal/**"}},
@@ -82,6 +82,34 @@ components:
82 }82 }
83 }83 }
84 84
85+func TestLoadRejectsExplicitZeroVersion(t *testing.T) {
86+ tests := []struct {
87+ name string
88+ yaml string
89+ }{
90+ {name: "zero", yaml: "version: 0\n"},
91+ {name: "positive zero", yaml: "version: +0\n"},
92+ {name: "double zero", yaml: "version: 00\n"},
93+ }
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ dir := t.TempDir()
97+ path := filepath.Join(dir, ".hexcheck.yaml")
98+ data := []byte(tt.yaml + `components:
99+ adapters:
100+ paths: [internal/infrastructure/**]
101+ role: adapter
102+`)
103+ if err := os.WriteFile(path, data, 0o600); err != nil {
104+ t.Fatal(err)
105+ }
106+ if _, err := Load(path); err == nil {
107+ t.Fatal("expected explicit zero version to be rejected")
108+ }
109+ })
110+ }
111+}
112+
85 func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) {113 func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) {
86 dir := t.TempDir()114 dir := t.TempDir()
87 path := filepath.Join(dir, ".hexcheck.yaml")115 path := filepath.Join(dir, ".hexcheck.yaml")
@@ -108,6 +136,29 @@ heuristics:
108 }136 }
109 }137 }
110 138
139+func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) {
140+ tests := []struct {
141+ name string
142+ mutate func(*Config)
143+ }{
144+ {name: "negative threshold", mutate: func(c *Config) { c.Heuristics.BusinessLogicThreshold = intPtr(-1) }},
145+ {name: "negative strong", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinStrongSignals = 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) }},
148+ {name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }},
149+ }
150+ for _, tt := range tests {
151+ t.Run(tt.name, func(t *testing.T) {
152+ cfg := &Config{Version: 1, Components: map[string]Component{"adapters": {Role: RoleAdapter, Paths: []string{"internal/**"}}}}
153+ cfg.applyDefaults()
154+ tt.mutate(cfg)
155+ if err := cfg.Validate(); err == nil {
156+ t.Fatal("expected validation error")
157+ }
158+ })
159+ }
160+}
161+
111 func TestComponentForPathPrecedence(t *testing.T) {162 func TestComponentForPathPrecedence(t *testing.T) {
112 cfg := &Config{Components: map[string]Component{163 cfg := &Config{Components: map[string]Component{
113 "all": {Role: RoleCore, Paths: []string{"internal/**"}},164 "all": {Role: RoleCore, Paths: []string{"internal/**"}},
M examples/hexcheck.yaml +18 -0
@@ -53,6 +53,9 @@ ruleSettings:
5353 suspicious-business-logic-in-adapter:
5454 excludePaths:
5555 - internal/ui/**
56+ - internal/cli/cmd/**
57+ - internal/cli/model/**
58+ - cmd/**
5659
5760 externalTypes:
5861 frameworkPackages: []
@@ -62,6 +65,10 @@ externalTypes:
6265
6366 heuristics:
6467 businessLogicThreshold: 8
68+ businessLogicMinStrongSignals: 2
69+ businessLogicMinWeakSignals: 2
70+ businessLogicMaxFunctionNodes: 2000
71+ businessLogicMaxDiagnosticsPerPackage: 10
6572 excludeTestFiles: true
6673 businessKeywords:
6774 - Validate
@@ -71,6 +78,17 @@ heuristics:
7178 - Apply
7279 - Transition
7380 - Can
81+ - Detect
82+ - Migrate
83+ - Resolve
84+ - Profile
85+ - Score
86+ - Ranking
87+ - Restore
88+ - Purge
89+ - Update
90+ - Performance
91+ - Selected
7492
7593 mocking:
7694 generatedMockPaths:
@@ -53,6 +53,9 @@ ruleSettings:
53 suspicious-business-logic-in-adapter:53 suspicious-business-logic-in-adapter:
54 excludePaths:54 excludePaths:
55 - internal/ui/**55 - internal/ui/**
56+ - internal/cli/cmd/**
57+ - internal/cli/model/**
58+ - cmd/**
56 59
57 externalTypes:60 externalTypes:
58 frameworkPackages: []61 frameworkPackages: []
@@ -62,6 +65,10 @@ externalTypes:
62 65
63 heuristics:66 heuristics:
64 businessLogicThreshold: 867 businessLogicThreshold: 8
68+ businessLogicMinStrongSignals: 2
69+ businessLogicMinWeakSignals: 2
70+ businessLogicMaxFunctionNodes: 2000
71+ businessLogicMaxDiagnosticsPerPackage: 10
65 excludeTestFiles: true72 excludeTestFiles: true
66 businessKeywords:73 businessKeywords:
67 - Validate74 - Validate
@@ -71,6 +78,17 @@ heuristics:
71 - Apply78 - Apply
72 - Transition79 - Transition
73 - Can80 - Can
81+ - Detect
82+ - Migrate
83+ - Resolve
84+ - Profile
85+ - Score
86+ - Ranking
87+ - Restore
88+ - Purge
89+ - Update
90+ - Performance
91+ - Selected
74 92
75 mocking:93 mocking:
76 generatedMockPaths:94 generatedMockPaths:

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