feat(analyzer): calibrate business logic heuristic
- commit
- 79118a8429eec94f601af156648625f4ef8764b7
- author
- brice · · 2026-05-30 09:00 UTC
- parent
- 4de0705
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 56 | 56 | func resolveConfig(opts Options, configPathFlag, rootFlag string) (*config.Config, string, error) { | |
| 57 | 57 | if opts.Config != nil { | |
| 58 | 58 | cfg := cloneConfig(opts.Config) | |
| 59 | + | cfg.ApplyDefaults() | |
| 60 | + | if err := cfg.Validate(); err != nil { | |
| 61 | + | return nil, "", err | |
| 62 | + | } | |
| 59 | 63 | if cfg.Root != "" { | |
| 60 | 64 | root, err := filepath.Abs(cfg.Root) | |
| 61 | 65 | if err != nil { | |
| @@ -142,6 +146,7 @@ func (r runner) run() { | |||
| 142 | 146 | if !ok || current.Role == config.RoleIgnore { | |
| 143 | 147 | return | |
| 144 | 148 | } | |
| 149 | + | businessDiagnostics := 0 | |
| 145 | 150 | for _, file := range r.pass.Files { | |
| 146 | 151 | filePath := r.filePath(file) | |
| 147 | 152 | if isGeneratedFile(file) { | |
| @@ -150,7 +155,7 @@ func (r runner) run() { | |||
| 150 | 155 | r.checkImports(file, filePath, current) | |
| 151 | 156 | r.checkTypeLeaks(file, filePath, current) | |
| 152 | 157 | r.checkMissingMocks(file, filePath, current) | |
| 153 | - | r.checkBusinessLogic(file, filePath, current) | |
| 158 | + | r.checkBusinessLogic(file, filePath, current, &businessDiagnostics) | |
| 154 | 159 | r.checkMocks(file, filePath, current) | |
| 155 | 160 | } | |
| 156 | 161 | } | |
| @@ -255,11 +260,12 @@ func (r runner) matchesExternal(pkg string) bool { | |||
| 255 | 260 | 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 | 264 | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) { | |
| 260 | - | return | |
| 265 | + | return false | |
| 261 | 266 | } | |
| 262 | 267 | r.pass.Reportf(pos, rule+": "+format, args...) | |
| 268 | + | return true | |
| 263 | 269 | } | |
| 264 | 270 | ||
| 265 | 271 | 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 | return | 147 | 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 false | 260 | 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 | - | return | 265 | + | 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) { | |||
| 14 | 14 | patterns []string | |
| 15 | 15 | }{ | |
| 16 | 16 | { | |
| 17 | - | name: "boundary imports and heuristics", | |
| 17 | + | name: "boundary imports", | |
| 18 | 18 | patterns: []string{ | |
| 19 | 19 | "example.com/project/internal/domain", | |
| 20 | 20 | "example.com/project/internal/application/usecase", | |
| 21 | + | }, | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "heuristics", | |
| 25 | + | patterns: []string{ | |
| 21 | 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 | 46 | func testConfig() *config.Config { | |
| 40 | 47 | cfg := &config.Config{ | |
| 41 | 48 | Root: filepath.Join(analysistest.TestData(), "src/example.com/project"), | |
| 42 | 49 | Version: 1, | |
| 43 | 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 | 52 | "usecase": {Role: config.RoleUsecase, Paths: []string{"internal/application/usecase/**"}}, | |
| 46 | 53 | "ports": {Role: config.RolePorts, Paths: []string{"internal/application/port/**"}}, | |
| 47 | 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 | 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 | 66 | ExternalTypes: config.ExternalTypes{ | |
| 52 | 67 | FrameworkPackages: []string{"example.com/framework"}, | |
| 53 | 68 | }, | |
| @@ -14,10 +14,15 @@ func TestAnalyzer(t *testing.T) { | |||||
| 14 | patterns []string | 14 | 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 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "go/ast" | |
| 6 | + | "go/token" | |
| 7 | + | "go/types" | |
| 5 | 8 | "strings" | |
| 6 | 9 | ||
| 7 | 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 | 19 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | |
| 12 | 20 | return | |
| 13 | 21 | } | |
| @@ -15,36 +23,255 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 15 | 23 | return | |
| 16 | 24 | } | |
| 17 | 25 | ||
| 18 | - | threshold := r.cfg.Heuristics.BusinessLogicThreshold | |
| 19 | 26 | for _, decl := range file.Decls { | |
| 20 | 27 | fn, ok := decl.(*ast.FuncDecl) | |
| 21 | 28 | if !ok || fn.Body == nil { | |
| 22 | 29 | 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 := 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{} | |
| 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 { | |
| 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 | |
| 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 | 217 | return true | |
| 38 | - | }) | |
| 218 | + | } | |
| 219 | + | } | |
| 220 | + | return false | |
| 221 | + | } | |
| 39 | 222 | ||
| 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 | |
| 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 | 273 | func containsWord(name, word string) bool { | |
| 47 | - | if word == "" { | |
| 274 | + | if word == "" || len(word) > len(name) { | |
| 48 | 275 | return false | |
| 49 | 276 | } | |
| 50 | 277 | for i := 0; i <= len(name)-len(word); i++ { | |
| @@ -1,13 +1,21 @@ | |||||
| 1 | package analyzer | 1 | 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 | return | 20 | return | ||
| 13 | } | 21 | } | ||
| @@ -15,36 +23,255 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 15 | return | 23 | 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 | continue | 29 | 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 := 0 | 107 | + | 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 += 2 | 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{} | |||
| 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 true | 217 | 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 false | 275 | 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 { | |||
| 19 | 19 | out.ExternalTypes.FrameworkPackages = append([]string(nil), in.ExternalTypes.FrameworkPackages...) | |
| 20 | 20 | out.ExternalTypes.AdapterTypePackages = append([]string(nil), in.ExternalTypes.AdapterTypePackages...) | |
| 21 | 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 | 27 | if in.Heuristics.ExcludeTestFiles != nil { | |
| 23 | 28 | value := *in.Heuristics.ExcludeTestFiles | |
| 24 | 29 | out.Heuristics.ExcludeTestFiles = &value | |
| @@ -33,3 +38,11 @@ func cloneConfig(in *config.Config) *config.Config { | |||
| 33 | 38 | out.Allow = append([]config.Allow(nil), in.Allow...) | |
| 34 | 39 | 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 | + | } | |
| @@ -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.ExcludeTestFiles | 28 | value := *in.Heuristics.ExcludeTestFiles | ||
| 24 | out.Heuristics.ExcludeTestFiles = &value | 29 | 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 &out | 39 | 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 @@ | |||
| 1 | 1 | 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 | 5 | type User struct{ Name string } | |
| @@ -1,5 +1,5 @@ | |||||
| 1 | package domain | 1 | 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 @@ | |||
| 1 | 1 | // Code generated by templ - DO NOT EDIT. | |
| 2 | - | package http | |
| 2 | + | package httpgen | |
| 3 | 3 | ||
| 4 | 4 | func ValidateGenerated(value int) bool { | |
| 5 | 5 | 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 http | 2 | + | 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 @@ | |||
| 1 | 1 | package http | |
| 2 | 2 | ||
| 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{} | |
| 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 | 15 | return false | |
| 9 | 16 | } | |
| 10 | - | if region == "" { | |
| 11 | - | return false | |
| 17 | + | if attempts == 1 { | |
| 18 | + | return true | |
| 12 | 19 | } | |
| 13 | - | if total > 1000 { | |
| 14 | - | return region == "trusted" | |
| 20 | + | if attempts == 2 { | |
| 21 | + | return true | |
| 15 | 22 | } | |
| 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 | |
| 18 | 34 | } | |
| 19 | - | if total == 42 { | |
| 35 | + | if attempts == 7 { | |
| 20 | 36 | return true | |
| 21 | 37 | } | |
| 22 | - | return true | |
| 38 | + | return false | |
| 23 | 39 | } | |
| @@ -1,23 +1,39 @@ | |||||
| 1 | package http | 1 | 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 false | 5 | + | 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 false | 15 | return false | ||
| 9 | } | 16 | } | ||
| 10 | - | if region == "" { | 17 | + | if attempts == 1 { |
| 11 | - | return false | 18 | + | 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 false | 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 | |||
| 18 | } | 34 | } | ||
| 19 | - | if total == 42 { | 35 | + | if attempts == 7 { |
| 20 | return true | 36 | return true | ||
| 21 | } | 37 | } | ||
| 22 | - | return true | 38 | + | 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 { | |||
| 53 | 53 | } | |
| 54 | 54 | ||
| 55 | 55 | 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"` | |
| 59 | 63 | } | |
| 60 | 64 | ||
| 61 | 65 | type Mocking struct { | |
| @@ -87,6 +91,9 @@ func Load(path string) (*Config, error) { | |||
| 87 | 91 | if err := yaml.Unmarshal(data, &cfg); err != nil { | |
| 88 | 92 | return nil, err | |
| 89 | 93 | } | |
| 94 | + | if hasExplicitVersion(data) && cfg.Version == 0 { | |
| 95 | + | return nil, errors.New("version: unsupported value 0") | |
| 96 | + | } | |
| 90 | 97 | root, err := filepath.Abs(filepath.Dir(path)) | |
| 91 | 98 | if err != nil { | |
| 92 | 99 | return nil, err | |
| @@ -105,6 +112,10 @@ func Default() *Config { | |||
| 105 | 112 | return cfg | |
| 106 | 113 | } | |
| 107 | 114 | ||
| 115 | + | func (c *Config) ApplyDefaults() { | |
| 116 | + | c.applyDefaults() | |
| 117 | + | } | |
| 118 | + | ||
| 108 | 119 | func (c *Config) applyDefaults() { | |
| 109 | 120 | if c.Version == 0 { | |
| 110 | 121 | c.Version = 1 | |
| @@ -117,21 +128,52 @@ func (c *Config) applyDefaults() { | |||
| 117 | 128 | c.Rules[rule] = sev | |
| 118 | 129 | } | |
| 119 | 130 | } | |
| 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) | |
| 122 | 145 | } | |
| 123 | 146 | if c.Heuristics.ExcludeTestFiles == nil { | |
| 124 | 147 | defaultExcludeTestFiles := true | |
| 125 | 148 | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | |
| 126 | 149 | } | |
| 127 | 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 | 153 | if len(c.Mocking.GeneratedMockNamePatterns) == 0 { | |
| 131 | 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 | 177 | func DefaultRuleSeverities() map[string]Severity { | |
| 136 | 178 | return map[string]Severity{ | |
| 137 | 179 | "no-adapter-imports-in-core": SeverityError, | |
| @@ -163,6 +205,21 @@ func (c *Config) Validate() error { | |||
| 163 | 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 | 223 | for rule, severity := range c.Rules { | |
| 167 | 224 | switch severity { | |
| 168 | 225 | 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, err | 92 | 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, err | 99 | return nil, err | ||
| @@ -105,6 +112,10 @@ func Default() *Config { | |||||
| 105 | return cfg | 112 | 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 = 1 | 121 | c.Version = 1 | ||
| @@ -117,21 +128,52 @@ func (c *Config) applyDefaults() { | |||||
| 117 | c.Rules[rule] = sev | 128 | c.Rules[rule] = sev | ||
| 118 | } | 129 | } | ||
| 119 | } | 130 | } | ||
| 120 | - | if c.Heuristics.BusinessLogicThreshold == 0 { | 131 | + | if c.Heuristics.BusinessLogicThreshold == nil { |
| 121 | - | c.Heuristics.BusinessLogicThreshold = 8 | 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) | |||
| 122 | } | 145 | } | ||
| 123 | if c.Heuristics.ExcludeTestFiles == nil { | 146 | if c.Heuristics.ExcludeTestFiles == nil { | ||
| 124 | defaultExcludeTestFiles := true | 147 | defaultExcludeTestFiles := true | ||
| 125 | c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles | 148 | 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: | |||
| 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 | 113 | func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) { | |
| 86 | 114 | dir := t.TempDir() | |
| 87 | 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 | 162 | func TestComponentForPathPrecedence(t *testing.T) { | |
| 112 | 163 | cfg := &Config{Components: map[string]Component{ | |
| 113 | 164 | "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: | |||
| 53 | 53 | suspicious-business-logic-in-adapter: | |
| 54 | 54 | excludePaths: | |
| 55 | 55 | - internal/ui/** | |
| 56 | + | - internal/cli/cmd/** | |
| 57 | + | - internal/cli/model/** | |
| 58 | + | - cmd/** | |
| 56 | 59 | ||
| 57 | 60 | externalTypes: | |
| 58 | 61 | frameworkPackages: [] | |
| @@ -62,6 +65,10 @@ externalTypes: | |||
| 62 | 65 | ||
| 63 | 66 | heuristics: | |
| 64 | 67 | businessLogicThreshold: 8 | |
| 68 | + | businessLogicMinStrongSignals: 2 | |
| 69 | + | businessLogicMinWeakSignals: 2 | |
| 70 | + | businessLogicMaxFunctionNodes: 2000 | |
| 71 | + | businessLogicMaxDiagnosticsPerPackage: 10 | |
| 65 | 72 | excludeTestFiles: true | |
| 66 | 73 | businessKeywords: | |
| 67 | 74 | - Validate | |
| @@ -71,6 +78,17 @@ heuristics: | |||
| 71 | 78 | - Apply | |
| 72 | 79 | - Transition | |
| 73 | 80 | - 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 | 93 | mocking: | |
| 76 | 94 | 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: 8 | 67 | businessLogicThreshold: 8 | ||
| 68 | + | businessLogicMinStrongSignals: 2 | |||
| 69 | + | businessLogicMinWeakSignals: 2 | |||
| 70 | + | businessLogicMaxFunctionNodes: 2000 | |||
| 71 | + | businessLogicMaxDiagnosticsPerPackage: 10 | |||
| 65 | excludeTestFiles: true | 72 | excludeTestFiles: true | ||
| 66 | businessKeywords: | 73 | businessKeywords: | ||
| 67 | - Validate | 74 | - Validate | ||
| @@ -71,6 +78,17 @@ heuristics: | |||||
| 71 | - Apply | 78 | - Apply | ||
| 72 | - Transition | 79 | - Transition | ||
| 73 | - Can | 80 | - 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: | ||