fix(analyzer): weight business-logic signals by decision context and report unused ignores
Domain calls and mutations become weak evidence; fresh local construction is not mutation. Strong evidence now needs a decision: domain predicates, typed numeric limits, guarded domain errors, domain-derived membership. Add unused-ignore-directive and business-logic-diagnostics-hidden (warn). Suppression bookkeeping runs before the per-package cap.
16 files · +771 -31
M CHANGELOG.md +11 -0
| @@ -1,5 +1,16 @@ | |||
| 1 | 1 | # Changelog | |
| 2 | 2 | ||
| 3 | + | ## Unreleased | |
| 4 | + | ||
| 5 | + | ### Fixed | |
| 6 | + | ||
| 7 | + | - Reduce adapter business-logic noise: domain calls and mutations are weak evidence, fresh local decoding is not mutation, and decision context distinguishes policy from transport. | |
| 8 | + | - Apply the business-logic package cap after suppression bookkeeping and report the hidden count with `business-logic-diagnostics-hidden` (warn). | |
| 9 | + | ||
| 10 | + | ### Added | |
| 11 | + | ||
| 12 | + | - Report inline ignore rules that suppress nothing as `unused-ignore-directive` (warn), including directives beyond the business-logic reporting cap. | |
| 13 | + | ||
| 3 | 14 | ## v0.3.1 | |
| 4 | 15 | ||
| 5 | 16 | ### Fixed | |
| @@ -1,5 +1,16 @@ | |||||
| 1 | # Changelog | 1 | # Changelog | ||
| 2 | 2 | ||||
| 3 | + | ## Unreleased | |||
| 4 | + | ||||
| 5 | + | ### Fixed | |||
| 6 | + | ||||
| 7 | + | - Reduce adapter business-logic noise: domain calls and mutations are weak evidence, fresh local decoding is not mutation, and decision context distinguishes policy from transport. | |||
| 8 | + | - Apply the business-logic package cap after suppression bookkeeping and report the hidden count with `business-logic-diagnostics-hidden` (warn). | |||
| 9 | + | ||||
| 10 | + | ### Added | |||
| 11 | + | ||||
| 12 | + | - Report inline ignore rules that suppress nothing as `unused-ignore-directive` (warn), including directives beyond the business-logic reporting cap. | |||
| 13 | + | ||||
| 3 | ## v0.3.1 | 14 | ## v0.3.1 | ||
| 4 | 15 | ||||
| 5 | ### Fixed | 16 | ### Fixed | ||
M README.md +5 -1
| @@ -115,12 +115,16 @@ Business-logic diagnostics include a confidence level. `audit` mode reports find | |||
| 115 | 115 | | `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type | | |
| 116 | 116 | | `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component | | |
| 117 | 117 | | `suspicious-business-logic-in-adapter` | warn | adapter or entrypoint function looks like business logic | | |
| 118 | + | | `business-logic-diagnostics-hidden` | warn | package exceeds the business-logic diagnostic cap; reports the hidden count | | |
| 119 | + | | `unused-ignore-directive` | warn | inline ignore rule suppresses no diagnostics in this run | | |
| 118 | 120 | | `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock | | |
| 119 | 121 | | `missing-generated-mock-for-port` | warn | port interface has no generated mock | | |
| 120 | 122 | | `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter | | |
| 121 | 123 | ||
| 122 | 124 | Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments. | |
| 123 | 125 | ||
| 126 | + | Business-logic evidence treats domain calls and mutations as weak signals. Field-by-field construction of fresh local domain values is not mutation evidence. Domain predicates (methods or package-level boolean functions) driving decisions, numeric domain operands in ordering comparisons, guarded domain errors (other than common storage sentinels), and domain-derived set membership (including comma-ok checks) provide stronger evidence with branching. `String`/`Error` formatting and `IsZero` checks alone do not. Business keywords need decision context rather than merely a domain-typed parameter (except explicit computation/scoring or validation with mutation). The per-package cap applies after inline suppression; one `business-logic-diagnostics-hidden` warning states how many additional findings it hides. | |
| 127 | + | ||
| 124 | 128 | The config is strict: unknown keys and unknown rule names are errors. | |
| 125 | 129 | ||
| 126 | 130 | ## Suppressing a finding | |
| @@ -134,7 +138,7 @@ type Rows interface { | |||
| 134 | 138 | } | |
| 135 | 139 | ``` | |
| 136 | 140 | ||
| 137 | - | The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`. | |
| 141 | + | The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`. Each listed rule that suppresses nothing in the current run produces `unused-ignore-directive` (warn by default), including when its rule is disabled or excluded. Business-logic suppression usage is tracked even beyond the package reporting cap. Unclassified, ignored, and generated files are not checked for unused directives. | |
| 138 | 142 | ||
| 139 | 143 | ## Standalone CLI | |
| 140 | 144 | ||
| @@ -115,12 +115,16 @@ Business-logic diagnostics include a confidence level. `audit` mode reports find | |||||
| 115 | | `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type | | 115 | | `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type | | ||
| 116 | | `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component | | 116 | | `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component | | ||
| 117 | | `suspicious-business-logic-in-adapter` | warn | adapter or entrypoint function looks like business logic | | 117 | | `suspicious-business-logic-in-adapter` | warn | adapter or entrypoint function looks like business logic | | ||
| 118 | + | | `business-logic-diagnostics-hidden` | warn | package exceeds the business-logic diagnostic cap; reports the hidden count | | |||
| 119 | + | | `unused-ignore-directive` | warn | inline ignore rule suppresses no diagnostics in this run | | |||
| 118 | | `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock | | 120 | | `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock | | ||
| 119 | | `missing-generated-mock-for-port` | warn | port interface has no generated mock | | 121 | | `missing-generated-mock-for-port` | warn | port interface has no generated mock | | ||
| 120 | | `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter | | 122 | | `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter | | ||
| 121 | 123 | ||||
| 122 | Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments. | 124 | Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments. | ||
| 123 | 125 | ||||
| 126 | + | Business-logic evidence treats domain calls and mutations as weak signals. Field-by-field construction of fresh local domain values is not mutation evidence. Domain predicates (methods or package-level boolean functions) driving decisions, numeric domain operands in ordering comparisons, guarded domain errors (other than common storage sentinels), and domain-derived set membership (including comma-ok checks) provide stronger evidence with branching. `String`/`Error` formatting and `IsZero` checks alone do not. Business keywords need decision context rather than merely a domain-typed parameter (except explicit computation/scoring or validation with mutation). The per-package cap applies after inline suppression; one `business-logic-diagnostics-hidden` warning states how many additional findings it hides. | |||
| 127 | + | ||||
| 124 | The config is strict: unknown keys and unknown rule names are errors. | 128 | The config is strict: unknown keys and unknown rule names are errors. | ||
| 125 | 129 | ||||
| 126 | ## Suppressing a finding | 130 | ## Suppressing a finding | ||
| @@ -134,7 +138,7 @@ type Rows interface { | |||||
| 134 | } | 138 | } | ||
| 135 | ``` | 139 | ``` | ||
| 136 | 140 | ||||
| 137 | - | The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`. | 141 | + | The directive covers its own line and the next one. It accepts a comma-separated rule list. A directive without a reason, or with an unknown rule, is reported as `invalid-ignore-directive`. Each listed rule that suppresses nothing in the current run produces `unused-ignore-directive` (warn by default), including when its rule is disabled or excluded. Business-logic suppression usage is tracked even beyond the package reporting cap. Unclassified, ignored, and generated files are not checked for unused directives. |
| 138 | 142 | ||||
| 139 | ## Standalone CLI | 143 | ## Standalone CLI | ||
| 140 | 144 | ||||
M analyzer/analyzer.go +30 -7
| @@ -172,11 +172,19 @@ func firstNonEmpty(values ...string) string { | |||
| 172 | 172 | } | |
| 173 | 173 | ||
| 174 | 174 | type runner struct { | |
| 175 | - | pass *analysis.Pass | |
| 176 | - | cfg *config.Config | |
| 177 | - | modulePath string | |
| 178 | - | state *state | |
| 179 | - | ignores ignoreIndex | |
| 175 | + | pass *analysis.Pass | |
| 176 | + | cfg *config.Config | |
| 177 | + | modulePath string | |
| 178 | + | state *state | |
| 179 | + | ignores ignoreIndex | |
| 180 | + | businessCap *businessDiagnosticCap | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | type businessDiagnosticCap struct { | |
| 184 | + | reported int | |
| 185 | + | hidden int | |
| 186 | + | pos token.Pos | |
| 187 | + | filePath string | |
| 180 | 188 | } | |
| 181 | 189 | ||
| 182 | 190 | func (r runner) run() { | |
| @@ -186,7 +194,7 @@ func (r runner) run() { | |||
| 186 | 194 | return | |
| 187 | 195 | } | |
| 188 | 196 | r.ignores = ignoreIndex{} | |
| 189 | - | businessDiagnostics := 0 | |
| 197 | + | r.businessCap = &businessDiagnosticCap{} | |
| 190 | 198 | for _, file := range r.pass.Files { | |
| 191 | 199 | filePath := r.filePath(file) | |
| 192 | 200 | if isGeneratedFile(file) { | |
| @@ -196,9 +204,13 @@ func (r runner) run() { | |||
| 196 | 204 | r.checkImports(file, filePath, current) | |
| 197 | 205 | r.checkTypeLeaks(file, filePath, current) | |
| 198 | 206 | r.checkMissingMocks(file, filePath, current) | |
| 199 | - | r.checkBusinessLogic(file, filePath, current, &businessDiagnostics) | |
| 207 | + | r.checkBusinessLogic(file, filePath, current) | |
| 200 | 208 | r.checkMocks(file, filePath, current) | |
| 201 | 209 | } | |
| 210 | + | if cap := r.businessCap; cap.hidden > 0 { | |
| 211 | + | r.report(cap.pos, "business-logic-diagnostics-hidden", cap.filePath, "%d more suspicious-business-logic-in-adapter diagnostics hidden by heuristics.businessLogicMaxDiagnosticsPerPackage", cap.hidden) | |
| 212 | + | } | |
| 213 | + | r.reportUnusedIgnores() | |
| 202 | 214 | } | |
| 203 | 215 | ||
| 204 | 216 | func (r runner) checkImports(file *ast.File, filePath string, current config.Match) { | |
| @@ -348,6 +360,17 @@ func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any | |||
| 348 | 360 | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) { | |
| 349 | 361 | return false | |
| 350 | 362 | } | |
| 363 | + | if rule == "suspicious-business-logic-in-adapter" && r.businessCap != nil { | |
| 364 | + | cap := r.businessCap | |
| 365 | + | if cap.reported >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | |
| 366 | + | cap.hidden++ | |
| 367 | + | if cap.pos == token.NoPos { | |
| 368 | + | cap.pos, cap.filePath = pos, filePath | |
| 369 | + | } | |
| 370 | + | return false | |
| 371 | + | } | |
| 372 | + | cap.reported++ | |
| 373 | + | } | |
| 351 | 374 | r.pass.Report(analysis.Diagnostic{ | |
| 352 | 375 | Pos: pos, | |
| 353 | 376 | Category: rule, | |
| @@ -172,11 +172,19 @@ func firstNonEmpty(values ...string) string { | |||||
| 172 | } | 172 | } | ||
| 173 | 173 | ||||
| 174 | type runner struct { | 174 | type runner struct { | ||
| 175 | - | pass *analysis.Pass | 175 | + | pass *analysis.Pass |
| 176 | - | cfg *config.Config | 176 | + | cfg *config.Config |
| 177 | - | modulePath string | 177 | + | modulePath string |
| 178 | - | state *state | 178 | + | state *state |
| 179 | - | ignores ignoreIndex | 179 | + | ignores ignoreIndex |
| 180 | + | businessCap *businessDiagnosticCap | |||
| 181 | + | } | |||
| 182 | + | ||||
| 183 | + | type businessDiagnosticCap struct { | |||
| 184 | + | reported int | |||
| 185 | + | hidden int | |||
| 186 | + | pos token.Pos | |||
| 187 | + | filePath string | |||
| 180 | } | 188 | } | ||
| 181 | 189 | ||||
| 182 | func (r runner) run() { | 190 | func (r runner) run() { | ||
| @@ -186,7 +194,7 @@ func (r runner) run() { | |||||
| 186 | return | 194 | return | ||
| 187 | } | 195 | } | ||
| 188 | r.ignores = ignoreIndex{} | 196 | r.ignores = ignoreIndex{} | ||
| 189 | - | businessDiagnostics := 0 | 197 | + | r.businessCap = &businessDiagnosticCap{} |
| 190 | for _, file := range r.pass.Files { | 198 | for _, file := range r.pass.Files { | ||
| 191 | filePath := r.filePath(file) | 199 | filePath := r.filePath(file) | ||
| 192 | if isGeneratedFile(file) { | 200 | if isGeneratedFile(file) { | ||
| @@ -196,9 +204,13 @@ func (r runner) run() { | |||||
| 196 | r.checkImports(file, filePath, current) | 204 | r.checkImports(file, filePath, current) | ||
| 197 | r.checkTypeLeaks(file, filePath, current) | 205 | r.checkTypeLeaks(file, filePath, current) | ||
| 198 | r.checkMissingMocks(file, filePath, current) | 206 | r.checkMissingMocks(file, filePath, current) | ||
| 199 | - | r.checkBusinessLogic(file, filePath, current, &businessDiagnostics) | 207 | + | r.checkBusinessLogic(file, filePath, current) |
| 200 | r.checkMocks(file, filePath, current) | 208 | r.checkMocks(file, filePath, current) | ||
| 201 | } | 209 | } | ||
| 210 | + | if cap := r.businessCap; cap.hidden > 0 { | |||
| 211 | + | r.report(cap.pos, "business-logic-diagnostics-hidden", cap.filePath, "%d more suspicious-business-logic-in-adapter diagnostics hidden by heuristics.businessLogicMaxDiagnosticsPerPackage", cap.hidden) | |||
| 212 | + | } | |||
| 213 | + | r.reportUnusedIgnores() | |||
| 202 | } | 214 | } | ||
| 203 | 215 | ||||
| 204 | func (r runner) checkImports(file *ast.File, filePath string, current config.Match) { | 216 | func (r runner) checkImports(file *ast.File, filePath string, current config.Match) { | ||
| @@ -348,6 +360,17 @@ func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any | |||||
| 348 | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) { | 360 | if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) { | ||
| 349 | return false | 361 | return false | ||
| 350 | } | 362 | } | ||
| 363 | + | if rule == "suspicious-business-logic-in-adapter" && r.businessCap != nil { | |||
| 364 | + | cap := r.businessCap | |||
| 365 | + | if cap.reported >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | |||
| 366 | + | cap.hidden++ | |||
| 367 | + | if cap.pos == token.NoPos { | |||
| 368 | + | cap.pos, cap.filePath = pos, filePath | |||
| 369 | + | } | |||
| 370 | + | return false | |||
| 371 | + | } | |||
| 372 | + | cap.reported++ | |||
| 373 | + | } | |||
| 351 | r.pass.Report(analysis.Diagnostic{ | 374 | r.pass.Report(analysis.Diagnostic{ | ||
| 352 | Pos: pos, | 375 | Pos: pos, | ||
| 353 | Category: rule, | 376 | Category: rule, | ||
M analyzer/analyzer_test.go +1 -1
| @@ -64,7 +64,7 @@ func testConfig() *config.Config { | |||
| 64 | 64 | BusinessLogicMinStrongSignals: testIntPtr(2), | |
| 65 | 65 | BusinessLogicMinWeakSignals: testIntPtr(2), | |
| 66 | 66 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | |
| 67 | - | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10), | |
| 67 | + | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(100), | |
| 68 | 68 | BusinessLogicMode: "audit", | |
| 69 | 69 | BusinessLogicMinConfidence: "medium", | |
| 70 | 70 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | |
| @@ -64,7 +64,7 @@ func testConfig() *config.Config { | |||||
| 64 | BusinessLogicMinStrongSignals: testIntPtr(2), | 64 | BusinessLogicMinStrongSignals: testIntPtr(2), | ||
| 65 | BusinessLogicMinWeakSignals: testIntPtr(2), | 65 | BusinessLogicMinWeakSignals: testIntPtr(2), | ||
| 66 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | 66 | BusinessLogicMaxFunctionNodes: testIntPtr(2000), | ||
| 67 | - | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10), | 67 | + | BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(100), |
| 68 | BusinessLogicMode: "audit", | 68 | BusinessLogicMode: "audit", | ||
| 69 | BusinessLogicMinConfidence: "medium", | 69 | BusinessLogicMinConfidence: "medium", | ||
| 70 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | 70 | BusinessKeywords: []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}, | ||
M analyzer/business_logic.go +258 -17
| @@ -24,7 +24,7 @@ type businessLogicSignal struct { | |||
| 24 | 24 | strong bool | |
| 25 | 25 | } | |
| 26 | 26 | ||
| 27 | - | func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) { | |
| 27 | + | func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) { | |
| 28 | 28 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | |
| 29 | 29 | return | |
| 30 | 30 | } | |
| @@ -37,12 +37,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||
| 37 | 37 | if !ok || fn.Body == nil { | |
| 38 | 38 | continue | |
| 39 | 39 | } | |
| 40 | - | if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | |
| 41 | - | return | |
| 42 | - | } | |
| 43 | - | if r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role) { | |
| 44 | - | *packageDiagnostics++ | |
| 45 | - | } | |
| 40 | + | r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role) | |
| 46 | 41 | } | |
| 47 | 42 | } | |
| 48 | 43 | ||
| @@ -72,6 +67,18 @@ func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.R | |||
| 72 | 67 | facts.imports[strings.Trim(imp.Path.Value, "\"")] = true | |
| 73 | 68 | } | |
| 74 | 69 | } | |
| 70 | + | // Reject oversized bodies before type queries or secondary traversals. | |
| 71 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |
| 72 | + | if n == nil { | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | facts.nodeCount++ | |
| 76 | + | return facts.nodeCount <= *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes | |
| 77 | + | }) | |
| 78 | + | if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | |
| 79 | + | return facts | |
| 80 | + | } | |
| 81 | + | facts.collectLocals() | |
| 75 | 82 | ast.Inspect(fn.Body, facts.inspect) | |
| 76 | 83 | return facts | |
| 77 | 84 | } | |
| @@ -86,6 +93,9 @@ type businessFunctionFacts struct { | |||
| 86 | 93 | keyword bool | |
| 87 | 94 | domainTypeUse bool | |
| 88 | 95 | domainMutation bool | |
| 96 | + | domainDecision bool | |
| 97 | + | zeroDecision bool | |
| 98 | + | guardedDomainError bool | |
| 89 | 99 | domainMethodCall bool | |
| 90 | 100 | domainConstructorCall bool | |
| 91 | 101 | domainErrorReturn bool | |
| @@ -93,27 +103,36 @@ type businessFunctionFacts struct { | |||
| 93 | 103 | usecaseCalls map[string]bool | |
| 94 | 104 | imports map[string]bool | |
| 95 | 105 | usesTechnicalPackage bool | |
| 106 | + | constructedLocals map[types.Object]bool | |
| 96 | 107 | } | |
| 97 | 108 | ||
| 98 | 109 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |
| 99 | 110 | if n == nil { | |
| 100 | 111 | return true | |
| 101 | 112 | } | |
| 102 | - | f.nodeCount++ | |
| 103 | - | if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | |
| 104 | - | return false | |
| 105 | - | } | |
| 106 | 113 | ||
| 107 | 114 | if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) { | |
| 108 | 115 | f.domainTypeUse = true | |
| 109 | 116 | } | |
| 110 | 117 | ||
| 111 | 118 | switch node := n.(type) { | |
| 112 | - | case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt: | |
| 119 | + | case *ast.IfStmt: | |
| 120 | + | f.branchCount++ | |
| 121 | + | f.inspectDecision(node.Cond) | |
| 122 | + | f.inspectMembershipInit(node) | |
| 123 | + | f.inspectPolicyError(node) | |
| 124 | + | case *ast.SwitchStmt: | |
| 125 | + | f.branchCount++ | |
| 126 | + | f.inspectDecision(node.Tag) | |
| 127 | + | case *ast.CaseClause: | |
| 128 | + | for _, expr := range node.List { | |
| 129 | + | f.inspectDecision(expr) | |
| 130 | + | } | |
| 131 | + | case *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt: | |
| 113 | 132 | f.branchCount++ | |
| 114 | 133 | case *ast.AssignStmt: | |
| 115 | 134 | for _, lhs := range node.Lhs { | |
| 116 | - | if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) { | |
| 135 | + | if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) && !f.isConstructedLocal(lhs) { | |
| 117 | 136 | f.domainMutation = true | |
| 118 | 137 | } | |
| 119 | 138 | } | |
| @@ -125,7 +144,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||
| 125 | 144 | } | |
| 126 | 145 | case *ast.ReturnStmt: | |
| 127 | 146 | for _, result := range node.Results { | |
| 128 | - | if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) { | |
| 147 | + | if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) || f.isDomainError(result) { | |
| 129 | 148 | f.domainErrorReturn = true | |
| 130 | 149 | } | |
| 131 | 150 | } | |
| @@ -133,6 +152,219 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||
| 133 | 152 | return true | |
| 134 | 153 | } | |
| 135 | 154 | ||
| 155 | + | // inspectDecision distinguishes domain decisions from merely transporting domain | |
| 156 | + | // values. Formatting calls do not become policy evidence in a comparison. | |
| 157 | + | func (f *businessFunctionFacts) inspectDecision(expr ast.Expr) { | |
| 158 | + | if expr == nil { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | ast.Inspect(expr, func(n ast.Node) bool { | |
| 162 | + | switch node := n.(type) { | |
| 163 | + | case *ast.IndexExpr: | |
| 164 | + | // A domain-derived key deciding membership is policy, unlike | |
| 165 | + | // formatting a key for a port call. | |
| 166 | + | ast.Inspect(node.Index, func(n ast.Node) bool { | |
| 167 | + | if call, ok := n.(*ast.CallExpr); ok { | |
| 168 | + | if sel, ok := call.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) { | |
| 169 | + | f.domainDecision = true | |
| 170 | + | } | |
| 171 | + | } | |
| 172 | + | return true | |
| 173 | + | }) | |
| 174 | + | case *ast.CallExpr: | |
| 175 | + | if fn, ok := f.calleeObject(node.Fun).(*types.Func); ok && f.coreObject(fn) { | |
| 176 | + | sig := fn.Type().(*types.Signature) | |
| 177 | + | if sig.Recv() == nil && sig.Results().Len() == 1 && types.Identical(sig.Results().At(0).Type(), types.Typ[types.Bool]) { | |
| 178 | + | f.domainDecision = true | |
| 179 | + | } | |
| 180 | + | } | |
| 181 | + | if sel, ok := node.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) { | |
| 182 | + | switch sel.Sel.Name { | |
| 183 | + | case "String", "Error": | |
| 184 | + | case "IsZero": | |
| 185 | + | f.zeroDecision = true | |
| 186 | + | default: | |
| 187 | + | f.domainDecision = true | |
| 188 | + | } | |
| 189 | + | } | |
| 190 | + | return false | |
| 191 | + | case *ast.BinaryExpr: | |
| 192 | + | switch node.Op { | |
| 193 | + | case token.LSS, token.LEQ, token.GTR, token.GEQ: | |
| 194 | + | if f.numericDomainOperand(node.X) || f.numericDomainOperand(node.Y) { | |
| 195 | + | f.domainDecision = true | |
| 196 | + | } | |
| 197 | + | } | |
| 198 | + | } | |
| 199 | + | return true | |
| 200 | + | }) | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | func (f *businessFunctionFacts) isDomainError(expr ast.Expr) bool { | |
| 204 | + | sel, ok := expr.(*ast.SelectorExpr) | |
| 205 | + | if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") { | |
| 206 | + | return false | |
| 207 | + | } | |
| 208 | + | obj := f.runner.pass.TypesInfo.ObjectOf(sel.Sel) | |
| 209 | + | if obj == nil || obj.Pkg() == nil { | |
| 210 | + | return false | |
| 211 | + | } | |
| 212 | + | match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path())) | |
| 213 | + | return ok && match.Role == config.RoleCore | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | func (f *businessFunctionFacts) calleeObject(expr ast.Expr) types.Object { | |
| 217 | + | switch expr := expr.(type) { | |
| 218 | + | case *ast.Ident: | |
| 219 | + | return f.runner.pass.TypesInfo.ObjectOf(expr) | |
| 220 | + | case *ast.SelectorExpr: | |
| 221 | + | return f.runner.pass.TypesInfo.ObjectOf(expr.Sel) | |
| 222 | + | } | |
| 223 | + | return nil | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | func (f *businessFunctionFacts) coreObject(obj types.Object) bool { | |
| 227 | + | if obj == nil || obj.Pkg() == nil { | |
| 228 | + | return false | |
| 229 | + | } | |
| 230 | + | match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path())) | |
| 231 | + | return ok && match.Role == config.RoleCore | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | func (f *businessFunctionFacts) numericDomainOperand(expr ast.Expr) bool { | |
| 235 | + | t := f.runner.pass.TypesInfo.TypeOf(expr) | |
| 236 | + | if t == nil { | |
| 237 | + | return false | |
| 238 | + | } | |
| 239 | + | basic, ok := t.Underlying().(*types.Basic) | |
| 240 | + | if !ok || basic.Info()&(types.IsInteger|types.IsFloat) == 0 { | |
| 241 | + | return false | |
| 242 | + | } | |
| 243 | + | switch expr := expr.(type) { | |
| 244 | + | case *ast.ParenExpr: | |
| 245 | + | return f.numericDomainOperand(expr.X) | |
| 246 | + | case *ast.SelectorExpr: | |
| 247 | + | return f.exprUsesRole(expr.X, config.RoleCore) || f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr.Sel)) | |
| 248 | + | case *ast.Ident: | |
| 249 | + | return f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr)) | |
| 250 | + | } | |
| 251 | + | return false | |
| 252 | + | } | |
| 253 | + | ||
| 254 | + | func (f *businessFunctionFacts) inspectMembershipInit(stmt *ast.IfStmt) { | |
| 255 | + | assign, ok := stmt.Init.(*ast.AssignStmt) | |
| 256 | + | if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 { | |
| 257 | + | return | |
| 258 | + | } | |
| 259 | + | index, ok := assign.Rhs[0].(*ast.IndexExpr) | |
| 260 | + | if !ok { | |
| 261 | + | return | |
| 262 | + | } | |
| 263 | + | t := f.runner.pass.TypesInfo.TypeOf(index.X) | |
| 264 | + | if t == nil { | |
| 265 | + | return | |
| 266 | + | } | |
| 267 | + | if _, ok := t.Underlying().(*types.Map); !ok { | |
| 268 | + | return | |
| 269 | + | } | |
| 270 | + | id, ok := assign.Lhs[1].(*ast.Ident) | |
| 271 | + | if !ok || id.Name == "_" { | |
| 272 | + | return | |
| 273 | + | } | |
| 274 | + | obj := f.runner.pass.TypesInfo.ObjectOf(id) | |
| 275 | + | // Only follow the comma-ok boolean when it participates in the condition. | |
| 276 | + | ast.Inspect(stmt.Cond, func(n ast.Node) bool { | |
| 277 | + | if use, ok := n.(*ast.Ident); ok && f.runner.pass.TypesInfo.ObjectOf(use) == obj { | |
| 278 | + | f.inspectDecision(index) | |
| 279 | + | } | |
| 280 | + | return true | |
| 281 | + | }) | |
| 282 | + | } | |
| 283 | + | ||
| 284 | + | // A guarded domain error is policy evidence unless it is a conventional | |
| 285 | + | // storage/validation sentinel. Those are routinely translated by adapters. | |
| 286 | + | func (f *businessFunctionFacts) inspectPolicyError(stmt *ast.IfStmt) { | |
| 287 | + | for _, s := range stmt.Body.List { | |
| 288 | + | ret, ok := s.(*ast.ReturnStmt) | |
| 289 | + | if !ok { | |
| 290 | + | continue | |
| 291 | + | } | |
| 292 | + | for _, result := range ret.Results { | |
| 293 | + | sel, ok := result.(*ast.SelectorExpr) | |
| 294 | + | if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") { | |
| 295 | + | continue | |
| 296 | + | } | |
| 297 | + | switch sel.Sel.Name { | |
| 298 | + | case "ErrNotFound", "ErrConflict", "ErrInvalid", "ErrAlreadyExists": | |
| 299 | + | continue | |
| 300 | + | } | |
| 301 | + | if f.isDomainError(result) { | |
| 302 | + | f.domainDecision = true | |
| 303 | + | f.guardedDomainError = true | |
| 304 | + | } | |
| 305 | + | } | |
| 306 | + | } | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // isConstructedLocal excludes field-by-field decoding into a fresh local | |
| 310 | + | // value. Values obtained from calls (including ports) remain mutation evidence. | |
| 311 | + | func (f *businessFunctionFacts) isConstructedLocal(expr ast.Expr) bool { | |
| 312 | + | for { | |
| 313 | + | sel, ok := expr.(*ast.SelectorExpr) | |
| 314 | + | if !ok { | |
| 315 | + | break | |
| 316 | + | } | |
| 317 | + | expr = sel.X | |
| 318 | + | } | |
| 319 | + | id, ok := expr.(*ast.Ident) | |
| 320 | + | if !ok { | |
| 321 | + | return false | |
| 322 | + | } | |
| 323 | + | return f.constructedLocals[f.runner.pass.TypesInfo.ObjectOf(id)] | |
| 324 | + | } | |
| 325 | + | ||
| 326 | + | func (f *businessFunctionFacts) collectLocals() { | |
| 327 | + | f.constructedLocals = map[types.Object]bool{} | |
| 328 | + | reassigned := map[types.Object]bool{} | |
| 329 | + | info := f.runner.pass.TypesInfo | |
| 330 | + | ast.Inspect(f.fn.Body, func(n ast.Node) bool { | |
| 331 | + | switch node := n.(type) { | |
| 332 | + | case *ast.ValueSpec: | |
| 333 | + | for i, name := range node.Names { | |
| 334 | + | if obj := info.Defs[name]; obj != nil { | |
| 335 | + | f.constructedLocals[obj] = len(node.Values) == 0 || (i < len(node.Values) && isFreshValue(node.Values[i])) | |
| 336 | + | } | |
| 337 | + | } | |
| 338 | + | case *ast.AssignStmt: | |
| 339 | + | for i, lhs := range node.Lhs { | |
| 340 | + | if name, ok := lhs.(*ast.Ident); ok { | |
| 341 | + | obj := info.ObjectOf(name) | |
| 342 | + | if obj == nil { | |
| 343 | + | continue | |
| 344 | + | } | |
| 345 | + | if info.Defs[name] == obj { | |
| 346 | + | f.constructedLocals[obj] = i < len(node.Rhs) && isFreshValue(node.Rhs[i]) | |
| 347 | + | } else { | |
| 348 | + | reassigned[obj] = true | |
| 349 | + | } | |
| 350 | + | } | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | return true | |
| 354 | + | }) | |
| 355 | + | for obj := range reassigned { | |
| 356 | + | delete(f.constructedLocals, obj) | |
| 357 | + | } | |
| 358 | + | } | |
| 359 | + | ||
| 360 | + | func isFreshValue(expr ast.Expr) bool { | |
| 361 | + | if unary, ok := expr.(*ast.UnaryExpr); ok && unary.Op == token.AND { | |
| 362 | + | expr = unary.X | |
| 363 | + | } | |
| 364 | + | _, ok := expr.(*ast.CompositeLit) | |
| 365 | + | return ok | |
| 366 | + | } | |
| 367 | + | ||
| 136 | 368 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | |
| 137 | 369 | f.inspectTechnicalCall(call) | |
| 138 | 370 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | |
| @@ -258,11 +490,20 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog | |||
| 258 | 490 | } | |
| 259 | 491 | } | |
| 260 | 492 | ||
| 493 | + | if f.zeroDecision && f.domainMutation { | |
| 494 | + | f.domainDecision = true | |
| 495 | + | } | |
| 261 | 496 | if f.domainMutation { | |
| 262 | - | strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true}) | |
| 497 | + | weak = append(weak, businessLogicSignal{kind: "domain mutation"}) | |
| 263 | 498 | } | |
| 264 | 499 | if f.domainMethodCall { | |
| 265 | - | strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true}) | |
| 500 | + | weak = append(weak, businessLogicSignal{kind: "domain method call"}) | |
| 501 | + | } | |
| 502 | + | if f.guardedDomainError && f.branchCount >= 2 { | |
| 503 | + | weak = append(weak, businessLogicSignal{kind: "guarded domain error"}) | |
| 504 | + | } | |
| 505 | + | if f.domainDecision && f.branchCount >= 2 { | |
| 506 | + | strong = append(strong, businessLogicSignal{kind: "domain-driven control flow", strong: true}) | |
| 266 | 507 | } | |
| 267 | 508 | if f.keyword && len(f.usecaseCalls) >= 2 { | |
| 268 | 509 | strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true}) | |
| @@ -270,7 +511,7 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog | |||
| 270 | 511 | if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) { | |
| 271 | 512 | strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true}) | |
| 272 | 513 | } | |
| 273 | - | if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) { | |
| 514 | + | if f.keyword && (f.domainDecision || (f.domainMutation && containsWord(f.fn.Name.Name, "Validate")) || (f.domainTypeUse && (containsWord(f.fn.Name.Name, "Compute") || containsWord(f.fn.Name.Name, "Calculate") || containsWord(f.fn.Name.Name, "Score")))) { | |
| 274 | 515 | strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true}) | |
| 275 | 516 | } | |
| 276 | 517 | ||
| @@ -24,7 +24,7 @@ type businessLogicSignal struct { | |||||
| 24 | strong bool | 24 | strong bool | ||
| 25 | } | 25 | } | ||
| 26 | 26 | ||||
| 27 | - | func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match, packageDiagnostics *int) { | 27 | + | func (r runner) checkBusinessLogic(file *ast.File, filePath string, current config.Match) { |
| 28 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | 28 | if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint { | ||
| 29 | return | 29 | return | ||
| 30 | } | 30 | } | ||
| @@ -37,12 +37,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf | |||||
| 37 | if !ok || fn.Body == nil { | 37 | if !ok || fn.Body == nil { | ||
| 38 | continue | 38 | continue | ||
| 39 | } | 39 | } | ||
| 40 | - | if *packageDiagnostics >= *r.cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage { | 40 | + | r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role) |
| 41 | - | return | |||
| 42 | - | } | |||
| 43 | - | if r.isSuspiciousBusinessFunction(fn, file, filePath, current.Role) { | |||
| 44 | - | *packageDiagnostics++ | |||
| 45 | - | } | |||
| 46 | } | 41 | } | ||
| 47 | } | 42 | } | ||
| 48 | 43 | ||||
| @@ -72,6 +67,18 @@ func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.R | |||||
| 72 | facts.imports[strings.Trim(imp.Path.Value, "\"")] = true | 67 | facts.imports[strings.Trim(imp.Path.Value, "\"")] = true | ||
| 73 | } | 68 | } | ||
| 74 | } | 69 | } | ||
| 70 | + | // Reject oversized bodies before type queries or secondary traversals. | |||
| 71 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |||
| 72 | + | if n == nil { | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | facts.nodeCount++ | |||
| 76 | + | return facts.nodeCount <= *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes | |||
| 77 | + | }) | |||
| 78 | + | if facts.nodeCount > *r.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | |||
| 79 | + | return facts | |||
| 80 | + | } | |||
| 81 | + | facts.collectLocals() | |||
| 75 | ast.Inspect(fn.Body, facts.inspect) | 82 | ast.Inspect(fn.Body, facts.inspect) | ||
| 76 | return facts | 83 | return facts | ||
| 77 | } | 84 | } | ||
| @@ -86,6 +93,9 @@ type businessFunctionFacts struct { | |||||
| 86 | keyword bool | 93 | keyword bool | ||
| 87 | domainTypeUse bool | 94 | domainTypeUse bool | ||
| 88 | domainMutation bool | 95 | domainMutation bool | ||
| 96 | + | domainDecision bool | |||
| 97 | + | zeroDecision bool | |||
| 98 | + | guardedDomainError bool | |||
| 89 | domainMethodCall bool | 99 | domainMethodCall bool | ||
| 90 | domainConstructorCall bool | 100 | domainConstructorCall bool | ||
| 91 | domainErrorReturn bool | 101 | domainErrorReturn bool | ||
| @@ -93,27 +103,36 @@ type businessFunctionFacts struct { | |||||
| 93 | usecaseCalls map[string]bool | 103 | usecaseCalls map[string]bool | ||
| 94 | imports map[string]bool | 104 | imports map[string]bool | ||
| 95 | usesTechnicalPackage bool | 105 | usesTechnicalPackage bool | ||
| 106 | + | constructedLocals map[types.Object]bool | |||
| 96 | } | 107 | } | ||
| 97 | 108 | ||||
| 98 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | 109 | func (f *businessFunctionFacts) inspect(n ast.Node) bool { | ||
| 99 | if n == nil { | 110 | if n == nil { | ||
| 100 | return true | 111 | return true | ||
| 101 | } | 112 | } | ||
| 102 | - | f.nodeCount++ | |||
| 103 | - | if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes { | |||
| 104 | - | return false | |||
| 105 | - | } | |||
| 106 | 113 | ||||
| 107 | if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) { | 114 | if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) { | ||
| 108 | f.domainTypeUse = true | 115 | f.domainTypeUse = true | ||
| 109 | } | 116 | } | ||
| 110 | 117 | ||||
| 111 | switch node := n.(type) { | 118 | switch node := n.(type) { | ||
| 112 | - | case *ast.IfStmt, *ast.SwitchStmt, *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt: | 119 | + | case *ast.IfStmt: |
| 120 | + | f.branchCount++ | |||
| 121 | + | f.inspectDecision(node.Cond) | |||
| 122 | + | f.inspectMembershipInit(node) | |||
| 123 | + | f.inspectPolicyError(node) | |||
| 124 | + | case *ast.SwitchStmt: | |||
| 125 | + | f.branchCount++ | |||
| 126 | + | f.inspectDecision(node.Tag) | |||
| 127 | + | case *ast.CaseClause: | |||
| 128 | + | for _, expr := range node.List { | |||
| 129 | + | f.inspectDecision(expr) | |||
| 130 | + | } | |||
| 131 | + | case *ast.TypeSwitchStmt, *ast.ForStmt, *ast.RangeStmt: | |||
| 113 | f.branchCount++ | 132 | f.branchCount++ | ||
| 114 | case *ast.AssignStmt: | 133 | case *ast.AssignStmt: | ||
| 115 | for _, lhs := range node.Lhs { | 134 | for _, lhs := range node.Lhs { | ||
| 116 | - | if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) { | 135 | + | if f.selectorReceiverUsesRole(lhs, config.RoleCore, config.RoleUsecase) && !f.isConstructedLocal(lhs) { |
| 117 | f.domainMutation = true | 136 | f.domainMutation = true | ||
| 118 | } | 137 | } | ||
| 119 | } | 138 | } | ||
| @@ -125,7 +144,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||||
| 125 | } | 144 | } | ||
| 126 | case *ast.ReturnStmt: | 145 | case *ast.ReturnStmt: | ||
| 127 | for _, result := range node.Results { | 146 | for _, result := range node.Results { | ||
| 128 | - | if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) { | 147 | + | if f.exprUsesRole(result, config.RoleCore) || f.exprUsesRole(result, config.RoleUsecase) || f.isDomainError(result) { |
| 129 | f.domainErrorReturn = true | 148 | f.domainErrorReturn = true | ||
| 130 | } | 149 | } | ||
| 131 | } | 150 | } | ||
| @@ -133,6 +152,219 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool { | |||||
| 133 | return true | 152 | return true | ||
| 134 | } | 153 | } | ||
| 135 | 154 | ||||
| 155 | + | // inspectDecision distinguishes domain decisions from merely transporting domain | |||
| 156 | + | // values. Formatting calls do not become policy evidence in a comparison. | |||
| 157 | + | func (f *businessFunctionFacts) inspectDecision(expr ast.Expr) { | |||
| 158 | + | if expr == nil { | |||
| 159 | + | return | |||
| 160 | + | } | |||
| 161 | + | ast.Inspect(expr, func(n ast.Node) bool { | |||
| 162 | + | switch node := n.(type) { | |||
| 163 | + | case *ast.IndexExpr: | |||
| 164 | + | // A domain-derived key deciding membership is policy, unlike | |||
| 165 | + | // formatting a key for a port call. | |||
| 166 | + | ast.Inspect(node.Index, func(n ast.Node) bool { | |||
| 167 | + | if call, ok := n.(*ast.CallExpr); ok { | |||
| 168 | + | if sel, ok := call.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) { | |||
| 169 | + | f.domainDecision = true | |||
| 170 | + | } | |||
| 171 | + | } | |||
| 172 | + | return true | |||
| 173 | + | }) | |||
| 174 | + | case *ast.CallExpr: | |||
| 175 | + | if fn, ok := f.calleeObject(node.Fun).(*types.Func); ok && f.coreObject(fn) { | |||
| 176 | + | sig := fn.Type().(*types.Signature) | |||
| 177 | + | if sig.Recv() == nil && sig.Results().Len() == 1 && types.Identical(sig.Results().At(0).Type(), types.Typ[types.Bool]) { | |||
| 178 | + | f.domainDecision = true | |||
| 179 | + | } | |||
| 180 | + | } | |||
| 181 | + | if sel, ok := node.Fun.(*ast.SelectorExpr); ok && f.exprUsesRole(sel.X, config.RoleCore) { | |||
| 182 | + | switch sel.Sel.Name { | |||
| 183 | + | case "String", "Error": | |||
| 184 | + | case "IsZero": | |||
| 185 | + | f.zeroDecision = true | |||
| 186 | + | default: | |||
| 187 | + | f.domainDecision = true | |||
| 188 | + | } | |||
| 189 | + | } | |||
| 190 | + | return false | |||
| 191 | + | case *ast.BinaryExpr: | |||
| 192 | + | switch node.Op { | |||
| 193 | + | case token.LSS, token.LEQ, token.GTR, token.GEQ: | |||
| 194 | + | if f.numericDomainOperand(node.X) || f.numericDomainOperand(node.Y) { | |||
| 195 | + | f.domainDecision = true | |||
| 196 | + | } | |||
| 197 | + | } | |||
| 198 | + | } | |||
| 199 | + | return true | |||
| 200 | + | }) | |||
| 201 | + | } | |||
| 202 | + | ||||
| 203 | + | func (f *businessFunctionFacts) isDomainError(expr ast.Expr) bool { | |||
| 204 | + | sel, ok := expr.(*ast.SelectorExpr) | |||
| 205 | + | if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") { | |||
| 206 | + | return false | |||
| 207 | + | } | |||
| 208 | + | obj := f.runner.pass.TypesInfo.ObjectOf(sel.Sel) | |||
| 209 | + | if obj == nil || obj.Pkg() == nil { | |||
| 210 | + | return false | |||
| 211 | + | } | |||
| 212 | + | match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path())) | |||
| 213 | + | return ok && match.Role == config.RoleCore | |||
| 214 | + | } | |||
| 215 | + | ||||
| 216 | + | func (f *businessFunctionFacts) calleeObject(expr ast.Expr) types.Object { | |||
| 217 | + | switch expr := expr.(type) { | |||
| 218 | + | case *ast.Ident: | |||
| 219 | + | return f.runner.pass.TypesInfo.ObjectOf(expr) | |||
| 220 | + | case *ast.SelectorExpr: | |||
| 221 | + | return f.runner.pass.TypesInfo.ObjectOf(expr.Sel) | |||
| 222 | + | } | |||
| 223 | + | return nil | |||
| 224 | + | } | |||
| 225 | + | ||||
| 226 | + | func (f *businessFunctionFacts) coreObject(obj types.Object) bool { | |||
| 227 | + | if obj == nil || obj.Pkg() == nil { | |||
| 228 | + | return false | |||
| 229 | + | } | |||
| 230 | + | match, ok := f.runner.cfg.ComponentForPath(f.runner.relImportPath(obj.Pkg().Path())) | |||
| 231 | + | return ok && match.Role == config.RoleCore | |||
| 232 | + | } | |||
| 233 | + | ||||
| 234 | + | func (f *businessFunctionFacts) numericDomainOperand(expr ast.Expr) bool { | |||
| 235 | + | t := f.runner.pass.TypesInfo.TypeOf(expr) | |||
| 236 | + | if t == nil { | |||
| 237 | + | return false | |||
| 238 | + | } | |||
| 239 | + | basic, ok := t.Underlying().(*types.Basic) | |||
| 240 | + | if !ok || basic.Info()&(types.IsInteger|types.IsFloat) == 0 { | |||
| 241 | + | return false | |||
| 242 | + | } | |||
| 243 | + | switch expr := expr.(type) { | |||
| 244 | + | case *ast.ParenExpr: | |||
| 245 | + | return f.numericDomainOperand(expr.X) | |||
| 246 | + | case *ast.SelectorExpr: | |||
| 247 | + | return f.exprUsesRole(expr.X, config.RoleCore) || f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr.Sel)) | |||
| 248 | + | case *ast.Ident: | |||
| 249 | + | return f.coreObject(f.runner.pass.TypesInfo.ObjectOf(expr)) | |||
| 250 | + | } | |||
| 251 | + | return false | |||
| 252 | + | } | |||
| 253 | + | ||||
| 254 | + | func (f *businessFunctionFacts) inspectMembershipInit(stmt *ast.IfStmt) { | |||
| 255 | + | assign, ok := stmt.Init.(*ast.AssignStmt) | |||
| 256 | + | if !ok || len(assign.Lhs) != 2 || len(assign.Rhs) != 1 { | |||
| 257 | + | return | |||
| 258 | + | } | |||
| 259 | + | index, ok := assign.Rhs[0].(*ast.IndexExpr) | |||
| 260 | + | if !ok { | |||
| 261 | + | return | |||
| 262 | + | } | |||
| 263 | + | t := f.runner.pass.TypesInfo.TypeOf(index.X) | |||
| 264 | + | if t == nil { | |||
| 265 | + | return | |||
| 266 | + | } | |||
| 267 | + | if _, ok := t.Underlying().(*types.Map); !ok { | |||
| 268 | + | return | |||
| 269 | + | } | |||
| 270 | + | id, ok := assign.Lhs[1].(*ast.Ident) | |||
| 271 | + | if !ok || id.Name == "_" { | |||
| 272 | + | return | |||
| 273 | + | } | |||
| 274 | + | obj := f.runner.pass.TypesInfo.ObjectOf(id) | |||
| 275 | + | // Only follow the comma-ok boolean when it participates in the condition. | |||
| 276 | + | ast.Inspect(stmt.Cond, func(n ast.Node) bool { | |||
| 277 | + | if use, ok := n.(*ast.Ident); ok && f.runner.pass.TypesInfo.ObjectOf(use) == obj { | |||
| 278 | + | f.inspectDecision(index) | |||
| 279 | + | } | |||
| 280 | + | return true | |||
| 281 | + | }) | |||
| 282 | + | } | |||
| 283 | + | ||||
| 284 | + | // A guarded domain error is policy evidence unless it is a conventional | |||
| 285 | + | // storage/validation sentinel. Those are routinely translated by adapters. | |||
| 286 | + | func (f *businessFunctionFacts) inspectPolicyError(stmt *ast.IfStmt) { | |||
| 287 | + | for _, s := range stmt.Body.List { | |||
| 288 | + | ret, ok := s.(*ast.ReturnStmt) | |||
| 289 | + | if !ok { | |||
| 290 | + | continue | |||
| 291 | + | } | |||
| 292 | + | for _, result := range ret.Results { | |||
| 293 | + | sel, ok := result.(*ast.SelectorExpr) | |||
| 294 | + | if !ok || !strings.HasPrefix(sel.Sel.Name, "Err") { | |||
| 295 | + | continue | |||
| 296 | + | } | |||
| 297 | + | switch sel.Sel.Name { | |||
| 298 | + | case "ErrNotFound", "ErrConflict", "ErrInvalid", "ErrAlreadyExists": | |||
| 299 | + | continue | |||
| 300 | + | } | |||
| 301 | + | if f.isDomainError(result) { | |||
| 302 | + | f.domainDecision = true | |||
| 303 | + | f.guardedDomainError = true | |||
| 304 | + | } | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | } | |||
| 308 | + | ||||
| 309 | + | // isConstructedLocal excludes field-by-field decoding into a fresh local | |||
| 310 | + | // value. Values obtained from calls (including ports) remain mutation evidence. | |||
| 311 | + | func (f *businessFunctionFacts) isConstructedLocal(expr ast.Expr) bool { | |||
| 312 | + | for { | |||
| 313 | + | sel, ok := expr.(*ast.SelectorExpr) | |||
| 314 | + | if !ok { | |||
| 315 | + | break | |||
| 316 | + | } | |||
| 317 | + | expr = sel.X | |||
| 318 | + | } | |||
| 319 | + | id, ok := expr.(*ast.Ident) | |||
| 320 | + | if !ok { | |||
| 321 | + | return false | |||
| 322 | + | } | |||
| 323 | + | return f.constructedLocals[f.runner.pass.TypesInfo.ObjectOf(id)] | |||
| 324 | + | } | |||
| 325 | + | ||||
| 326 | + | func (f *businessFunctionFacts) collectLocals() { | |||
| 327 | + | f.constructedLocals = map[types.Object]bool{} | |||
| 328 | + | reassigned := map[types.Object]bool{} | |||
| 329 | + | info := f.runner.pass.TypesInfo | |||
| 330 | + | ast.Inspect(f.fn.Body, func(n ast.Node) bool { | |||
| 331 | + | switch node := n.(type) { | |||
| 332 | + | case *ast.ValueSpec: | |||
| 333 | + | for i, name := range node.Names { | |||
| 334 | + | if obj := info.Defs[name]; obj != nil { | |||
| 335 | + | f.constructedLocals[obj] = len(node.Values) == 0 || (i < len(node.Values) && isFreshValue(node.Values[i])) | |||
| 336 | + | } | |||
| 337 | + | } | |||
| 338 | + | case *ast.AssignStmt: | |||
| 339 | + | for i, lhs := range node.Lhs { | |||
| 340 | + | if name, ok := lhs.(*ast.Ident); ok { | |||
| 341 | + | obj := info.ObjectOf(name) | |||
| 342 | + | if obj == nil { | |||
| 343 | + | continue | |||
| 344 | + | } | |||
| 345 | + | if info.Defs[name] == obj { | |||
| 346 | + | f.constructedLocals[obj] = i < len(node.Rhs) && isFreshValue(node.Rhs[i]) | |||
| 347 | + | } else { | |||
| 348 | + | reassigned[obj] = true | |||
| 349 | + | } | |||
| 350 | + | } | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | return true | |||
| 354 | + | }) | |||
| 355 | + | for obj := range reassigned { | |||
| 356 | + | delete(f.constructedLocals, obj) | |||
| 357 | + | } | |||
| 358 | + | } | |||
| 359 | + | ||||
| 360 | + | func isFreshValue(expr ast.Expr) bool { | |||
| 361 | + | if unary, ok := expr.(*ast.UnaryExpr); ok && unary.Op == token.AND { | |||
| 362 | + | expr = unary.X | |||
| 363 | + | } | |||
| 364 | + | _, ok := expr.(*ast.CompositeLit) | |||
| 365 | + | return ok | |||
| 366 | + | } | |||
| 367 | + | ||||
| 136 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | 368 | func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) { | ||
| 137 | f.inspectTechnicalCall(call) | 369 | f.inspectTechnicalCall(call) | ||
| 138 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | 370 | calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun) | ||
| @@ -258,11 +490,20 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog | |||||
| 258 | } | 490 | } | ||
| 259 | } | 491 | } | ||
| 260 | 492 | ||||
| 493 | + | if f.zeroDecision && f.domainMutation { | |||
| 494 | + | f.domainDecision = true | |||
| 495 | + | } | |||
| 261 | if f.domainMutation { | 496 | if f.domainMutation { | ||
| 262 | - | strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true}) | 497 | + | weak = append(weak, businessLogicSignal{kind: "domain mutation"}) |
| 263 | } | 498 | } | ||
| 264 | if f.domainMethodCall { | 499 | if f.domainMethodCall { | ||
| 265 | - | strong = append(strong, businessLogicSignal{kind: "domain method call", strong: true}) | 500 | + | weak = append(weak, businessLogicSignal{kind: "domain method call"}) |
| 501 | + | } | |||
| 502 | + | if f.guardedDomainError && f.branchCount >= 2 { | |||
| 503 | + | weak = append(weak, businessLogicSignal{kind: "guarded domain error"}) | |||
| 504 | + | } | |||
| 505 | + | if f.domainDecision && f.branchCount >= 2 { | |||
| 506 | + | strong = append(strong, businessLogicSignal{kind: "domain-driven control flow", strong: true}) | |||
| 266 | } | 507 | } | ||
| 267 | if f.keyword && len(f.usecaseCalls) >= 2 { | 508 | if f.keyword && len(f.usecaseCalls) >= 2 { | ||
| 268 | strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true}) | 509 | strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true}) | ||
| @@ -270,7 +511,7 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog | |||||
| 270 | if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) { | 511 | if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) { | ||
| 271 | strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true}) | 512 | strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true}) | ||
| 272 | } | 513 | } | ||
| 273 | - | if f.keyword && (f.domainTypeUse || f.domainMethodCall || f.domainMutation || f.domainConstructorCall) { | 514 | + | if f.keyword && (f.domainDecision || (f.domainMutation && containsWord(f.fn.Name.Name, "Validate")) || (f.domainTypeUse && (containsWord(f.fn.Name.Name, "Compute") || containsWord(f.fn.Name.Name, "Calculate") || containsWord(f.fn.Name.Name, "Score")))) { |
| 274 | strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true}) | 515 | strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true}) | ||
| 275 | } | 516 | } | ||
| 276 | 517 | ||||
M analyzer/business_logic_facts_test.go +59 -0
| @@ -2,6 +2,8 @@ package analyzer | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "go/ast" | |
| 5 | + | "go/parser" | |
| 6 | + | "go/token" | |
| 5 | 7 | "go/types" | |
| 6 | 8 | "testing" | |
| 7 | 9 | ||
| @@ -9,6 +11,63 @@ import ( | |||
| 9 | 11 | "golang.org/x/tools/go/analysis" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| 14 | + | func TestOversizedFunctionSkipsTypeAndLocalCollection(t *testing.T) { | |
| 15 | + | fn := &ast.FuncDecl{Body: &ast.BlockStmt{List: []ast.Stmt{ | |
| 16 | + | &ast.AssignStmt{Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent("repo"), Sel: ast.NewIdent("Name")}}, Rhs: []ast.Expr{ast.NewIdent("value")}}, | |
| 17 | + | }}} | |
| 18 | + | // No analysis pass: any type query before rejection would panic. | |
| 19 | + | r := runner{cfg: &config.Config{Heuristics: config.Heuristics{BusinessLogicMaxFunctionNodes: testIntPtr(1)}}} | |
| 20 | + | facts := r.collectBusinessFunctionFacts(fn, config.RoleAdapter) | |
| 21 | + | if facts.nodeCount <= 1 || facts.constructedLocals != nil || facts.domainMutation { | |
| 22 | + | t.Fatalf("oversized function was analyzed: %+v", facts) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestConstructionFactsCachedByObject(t *testing.T) { | |
| 27 | + | fset := token.NewFileSet() | |
| 28 | + | file, err := parser.ParseFile(fset, "locals.go", `package p | |
| 29 | + | type Value struct { N int } | |
| 30 | + | func fetch() Value { return Value{} } | |
| 31 | + | func build(param Value) { | |
| 32 | + | fresh := Value{} | |
| 33 | + | fresh.N = 1 | |
| 34 | + | var zero Value | |
| 35 | + | zero.N = 2 | |
| 36 | + | replaced := Value{} | |
| 37 | + | replaced = fetch() | |
| 38 | + | replaced.N = 3 | |
| 39 | + | fetched := fetch() | |
| 40 | + | fetched.N = 4 | |
| 41 | + | param.N = 5 | |
| 42 | + | _ = fresh; _ = zero; _ = replaced; _ = fetched | |
| 43 | + | }`, 0) | |
| 44 | + | if err != nil { | |
| 45 | + | t.Fatal(err) | |
| 46 | + | } | |
| 47 | + | info := &types.Info{Defs: map[*ast.Ident]types.Object{}, Uses: map[*ast.Ident]types.Object{}, Types: map[ast.Expr]types.TypeAndValue{}} | |
| 48 | + | if _, err := new(types.Config).Check("p", fset, []*ast.File{file}, info); err != nil { | |
| 49 | + | t.Fatal(err) | |
| 50 | + | } | |
| 51 | + | fn := file.Decls[2].(*ast.FuncDecl) | |
| 52 | + | facts := businessFunctionFacts{fn: fn, runner: runner{pass: &analysis.Pass{TypesInfo: info}}} | |
| 53 | + | facts.collectLocals() | |
| 54 | + | want := map[string]bool{"fresh": true, "zero": true, "replaced": false, "fetched": false, "param": false} | |
| 55 | + | checked := 0 | |
| 56 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |
| 57 | + | if sel, ok := n.(*ast.SelectorExpr); ok { | |
| 58 | + | id := sel.X.(*ast.Ident) | |
| 59 | + | if got := facts.isConstructedLocal(sel); got != want[id.Name] { | |
| 60 | + | t.Errorf("%s constructed=%v, want %v", id.Name, got, want[id.Name]) | |
| 61 | + | } | |
| 62 | + | checked++ | |
| 63 | + | } | |
| 64 | + | return true | |
| 65 | + | }) | |
| 66 | + | if checked != len(want) { | |
| 67 | + | t.Fatalf("checked %d receivers, want %d", checked, len(want)) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 12 | 71 | func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) { | |
| 13 | 72 | fn := &ast.FuncDecl{ | |
| 14 | 73 | Name: ast.NewIdent("ResolveMode"), | |
| @@ -2,6 +2,8 @@ package analyzer | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "go/ast" | 4 | "go/ast" | ||
| 5 | + | "go/parser" | |||
| 6 | + | "go/token" | |||
| 5 | "go/types" | 7 | "go/types" | ||
| 6 | "testing" | 8 | "testing" | ||
| 7 | 9 | ||||
| @@ -9,6 +11,63 @@ import ( | |||||
| 9 | "golang.org/x/tools/go/analysis" | 11 | "golang.org/x/tools/go/analysis" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| 14 | + | func TestOversizedFunctionSkipsTypeAndLocalCollection(t *testing.T) { | |||
| 15 | + | fn := &ast.FuncDecl{Body: &ast.BlockStmt{List: []ast.Stmt{ | |||
| 16 | + | &ast.AssignStmt{Lhs: []ast.Expr{&ast.SelectorExpr{X: ast.NewIdent("repo"), Sel: ast.NewIdent("Name")}}, Rhs: []ast.Expr{ast.NewIdent("value")}}, | |||
| 17 | + | }}} | |||
| 18 | + | // No analysis pass: any type query before rejection would panic. | |||
| 19 | + | r := runner{cfg: &config.Config{Heuristics: config.Heuristics{BusinessLogicMaxFunctionNodes: testIntPtr(1)}}} | |||
| 20 | + | facts := r.collectBusinessFunctionFacts(fn, config.RoleAdapter) | |||
| 21 | + | if facts.nodeCount <= 1 || facts.constructedLocals != nil || facts.domainMutation { | |||
| 22 | + | t.Fatalf("oversized function was analyzed: %+v", facts) | |||
| 23 | + | } | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func TestConstructionFactsCachedByObject(t *testing.T) { | |||
| 27 | + | fset := token.NewFileSet() | |||
| 28 | + | file, err := parser.ParseFile(fset, "locals.go", `package p | |||
| 29 | + | type Value struct { N int } | |||
| 30 | + | func fetch() Value { return Value{} } | |||
| 31 | + | func build(param Value) { | |||
| 32 | + | fresh := Value{} | |||
| 33 | + | fresh.N = 1 | |||
| 34 | + | var zero Value | |||
| 35 | + | zero.N = 2 | |||
| 36 | + | replaced := Value{} | |||
| 37 | + | replaced = fetch() | |||
| 38 | + | replaced.N = 3 | |||
| 39 | + | fetched := fetch() | |||
| 40 | + | fetched.N = 4 | |||
| 41 | + | param.N = 5 | |||
| 42 | + | _ = fresh; _ = zero; _ = replaced; _ = fetched | |||
| 43 | + | }`, 0) | |||
| 44 | + | if err != nil { | |||
| 45 | + | t.Fatal(err) | |||
| 46 | + | } | |||
| 47 | + | info := &types.Info{Defs: map[*ast.Ident]types.Object{}, Uses: map[*ast.Ident]types.Object{}, Types: map[ast.Expr]types.TypeAndValue{}} | |||
| 48 | + | if _, err := new(types.Config).Check("p", fset, []*ast.File{file}, info); err != nil { | |||
| 49 | + | t.Fatal(err) | |||
| 50 | + | } | |||
| 51 | + | fn := file.Decls[2].(*ast.FuncDecl) | |||
| 52 | + | facts := businessFunctionFacts{fn: fn, runner: runner{pass: &analysis.Pass{TypesInfo: info}}} | |||
| 53 | + | facts.collectLocals() | |||
| 54 | + | want := map[string]bool{"fresh": true, "zero": true, "replaced": false, "fetched": false, "param": false} | |||
| 55 | + | checked := 0 | |||
| 56 | + | ast.Inspect(fn.Body, func(n ast.Node) bool { | |||
| 57 | + | if sel, ok := n.(*ast.SelectorExpr); ok { | |||
| 58 | + | id := sel.X.(*ast.Ident) | |||
| 59 | + | if got := facts.isConstructedLocal(sel); got != want[id.Name] { | |||
| 60 | + | t.Errorf("%s constructed=%v, want %v", id.Name, got, want[id.Name]) | |||
| 61 | + | } | |||
| 62 | + | checked++ | |||
| 63 | + | } | |||
| 64 | + | return true | |||
| 65 | + | }) | |||
| 66 | + | if checked != len(want) { | |||
| 67 | + | t.Fatalf("checked %d receivers, want %d", checked, len(want)) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | ||||
| 12 | func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) { | 71 | func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) { | ||
| 13 | fn := &ast.FuncDecl{ | 72 | fn := &ast.FuncDecl{ | ||
| 14 | Name: ast.NewIdent("ResolveMode"), | 73 | Name: ast.NewIdent("ResolveMode"), | ||
M analyzer/business_logic_golden_test.go +35 -0
| @@ -5,6 +5,8 @@ import ( | |||
| 5 | 5 | "strings" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/hexcheck/config" | |
| 9 | + | ||
| 8 | 10 | "golang.org/x/tools/go/analysis/analysistest" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| @@ -37,6 +39,39 @@ func TestBusinessLogicGoldenCases(t *testing.T) { | |||
| 37 | 39 | } | |
| 38 | 40 | } | |
| 39 | 41 | ||
| 42 | + | func TestBusinessLogicCapGolden(t *testing.T) { | |
| 43 | + | cfg := testConfig() | |
| 44 | + | cfg.Components["capped"] = config.Component{Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/capped/**"}} | |
| 45 | + | cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = testIntPtr(1) | |
| 46 | + | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/capped") | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | func TestUsedIgnoreInTestPackageVariants(t *testing.T) { | |
| 50 | + | cfg := testConfig() | |
| 51 | + | cfg.Components["variants"] = config.Component{Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/variants/**"}} | |
| 52 | + | results := analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/variants") | |
| 53 | + | plain, withTests := false, false | |
| 54 | + | for _, result := range results { | |
| 55 | + | if len(result.Diagnostics) != 0 { | |
| 56 | + | t.Fatalf("%s: unexpected diagnostics: %v", result.Pass.Pkg.Path(), result.Diagnostics) | |
| 57 | + | } | |
| 58 | + | hasTestFile := false | |
| 59 | + | for _, file := range result.Pass.Files { | |
| 60 | + | if strings.HasSuffix(result.Pass.Fset.Position(file.Pos()).Filename, "_test.go") { | |
| 61 | + | hasTestFile = true | |
| 62 | + | } | |
| 63 | + | } | |
| 64 | + | if hasTestFile { | |
| 65 | + | withTests = true | |
| 66 | + | } else { | |
| 67 | + | plain = true | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | if !plain || !withTests { | |
| 71 | + | t.Fatalf("expected both ordinary and test package variants; plain=%v withTests=%v", plain, withTests) | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 40 | 75 | func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool { | |
| 41 | 76 | for file, rules := range rulesByFile { | |
| 42 | 77 | if !strings.HasSuffix(file, fileSuffix) { | |
| @@ -5,6 +5,8 @@ import ( | |||||
| 5 | "strings" | 5 | "strings" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/hexcheck/config" | |||
| 9 | + | ||||
| 8 | "golang.org/x/tools/go/analysis/analysistest" | 10 | "golang.org/x/tools/go/analysis/analysistest" | ||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| @@ -37,6 +39,39 @@ func TestBusinessLogicGoldenCases(t *testing.T) { | |||||
| 37 | } | 39 | } | ||
| 38 | } | 40 | } | ||
| 39 | 41 | ||||
| 42 | + | func TestBusinessLogicCapGolden(t *testing.T) { | |||
| 43 | + | cfg := testConfig() | |||
| 44 | + | cfg.Components["capped"] = config.Component{Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/capped/**"}} | |||
| 45 | + | cfg.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = testIntPtr(1) | |||
| 46 | + | analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/capped") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestUsedIgnoreInTestPackageVariants(t *testing.T) { | |||
| 50 | + | cfg := testConfig() | |||
| 51 | + | cfg.Components["variants"] = config.Component{Role: config.RoleAdapter, Paths: []string{"internal/infrastructure/variants/**"}} | |||
| 52 | + | results := analysistest.Run(t, analysistest.TestData(), New(Options{Config: cfg, ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/variants") | |||
| 53 | + | plain, withTests := false, false | |||
| 54 | + | for _, result := range results { | |||
| 55 | + | if len(result.Diagnostics) != 0 { | |||
| 56 | + | t.Fatalf("%s: unexpected diagnostics: %v", result.Pass.Pkg.Path(), result.Diagnostics) | |||
| 57 | + | } | |||
| 58 | + | hasTestFile := false | |||
| 59 | + | for _, file := range result.Pass.Files { | |||
| 60 | + | if strings.HasSuffix(result.Pass.Fset.Position(file.Pos()).Filename, "_test.go") { | |||
| 61 | + | hasTestFile = true | |||
| 62 | + | } | |||
| 63 | + | } | |||
| 64 | + | if hasTestFile { | |||
| 65 | + | withTests = true | |||
| 66 | + | } else { | |||
| 67 | + | plain = true | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | if !plain || !withTests { | |||
| 71 | + | t.Fatalf("expected both ordinary and test package variants; plain=%v withTests=%v", plain, withTests) | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 40 | func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool { | 75 | func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool { | ||
| 41 | for file, rules := range rulesByFile { | 76 | for file, rules := range rulesByFile { | ||
| 42 | if !strings.HasSuffix(file, fileSuffix) { | 77 | if !strings.HasSuffix(file, fileSuffix) { | ||
M analyzer/ignore.go +41 -4
| @@ -3,6 +3,7 @@ package analyzer | |||
| 3 | 3 | import ( | |
| 4 | 4 | "go/ast" | |
| 5 | 5 | "go/token" | |
| 6 | + | "sort" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/hexcheck/config" | |
| @@ -22,7 +23,14 @@ type ignoreKey struct { | |||
| 22 | 23 | } | |
| 23 | 24 | ||
| 24 | 25 | // ignoreIndex records which rules are suppressed on which lines. | |
| 25 | - | type ignoreIndex map[ignoreKey]bool | |
| 26 | + | type ignoreIndex map[ignoreKey][]*ignoreUsage | |
| 27 | + | ||
| 28 | + | type ignoreUsage struct { | |
| 29 | + | pos token.Pos | |
| 30 | + | rule string | |
| 31 | + | used bool | |
| 32 | + | filePath string | |
| 33 | + | } | |
| 26 | 34 | ||
| 27 | 35 | // collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives. | |
| 28 | 36 | // A directive suppresses matching diagnostics on its own line and on the | |
| @@ -46,8 +54,11 @@ func (r runner) collectIgnores(file *ast.File) { | |||
| 46 | 54 | r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule) | |
| 47 | 55 | continue | |
| 48 | 56 | } | |
| 49 | - | r.ignores[ignoreKey{file: pos.Filename, line: pos.Line, rule: rule}] = true | |
| 50 | - | r.ignores[ignoreKey{file: pos.Filename, line: pos.Line + 1, rule: rule}] = true | |
| 57 | + | usage := &ignoreUsage{pos: comment.Pos(), rule: rule, filePath: r.filePath(file)} | |
| 58 | + | for _, line := range []int{pos.Line, pos.Line + 1} { | |
| 59 | + | key := ignoreKey{file: pos.Filename, line: line, rule: rule} | |
| 60 | + | r.ignores[key] = append(r.ignores[key], usage) | |
| 61 | + | } | |
| 51 | 62 | } | |
| 52 | 63 | } | |
| 53 | 64 | } | |
| @@ -58,7 +69,33 @@ func (r runner) isIgnoredInline(pos token.Pos, rule string) bool { | |||
| 58 | 69 | return false | |
| 59 | 70 | } | |
| 60 | 71 | p := r.pass.Fset.Position(pos) | |
| 61 | - | return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}] | |
| 72 | + | usages := r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}] | |
| 73 | + | for _, usage := range usages { | |
| 74 | + | usage.used = true | |
| 75 | + | } | |
| 76 | + | return len(usages) > 0 | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func (r runner) reportUnusedIgnores() { | |
| 80 | + | seen := map[*ignoreUsage]bool{} | |
| 81 | + | var unused []*ignoreUsage | |
| 82 | + | for _, usages := range r.ignores { | |
| 83 | + | for _, usage := range usages { | |
| 84 | + | if !seen[usage] && !usage.used { | |
| 85 | + | unused = append(unused, usage) | |
| 86 | + | } | |
| 87 | + | seen[usage] = true | |
| 88 | + | } | |
| 89 | + | } | |
| 90 | + | sort.Slice(unused, func(i, j int) bool { | |
| 91 | + | if unused[i].pos != unused[j].pos { | |
| 92 | + | return unused[i].pos < unused[j].pos | |
| 93 | + | } | |
| 94 | + | return unused[i].rule < unused[j].rule | |
| 95 | + | }) | |
| 96 | + | for _, usage := range unused { | |
| 97 | + | r.report(usage.pos, "unused-ignore-directive", usage.filePath, "hexcheck:ignore %s suppresses no diagnostics", usage.rule) | |
| 98 | + | } | |
| 62 | 99 | } | |
| 63 | 100 | ||
| 64 | 101 | func (r runner) reportDirective(pos token.Pos, msg string) { | |
| @@ -3,6 +3,7 @@ package analyzer | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "go/ast" | 4 | "go/ast" | ||
| 5 | "go/token" | 5 | "go/token" | ||
| 6 | + | "sort" | |||
| 6 | "strings" | 7 | "strings" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/hexcheck/config" | 9 | "github.com/bnema/hexcheck/config" | ||
| @@ -22,7 +23,14 @@ type ignoreKey struct { | |||||
| 22 | } | 23 | } | ||
| 23 | 24 | ||||
| 24 | // ignoreIndex records which rules are suppressed on which lines. | 25 | // ignoreIndex records which rules are suppressed on which lines. | ||
| 25 | - | type ignoreIndex map[ignoreKey]bool | 26 | + | type ignoreIndex map[ignoreKey][]*ignoreUsage |
| 27 | + | ||||
| 28 | + | type ignoreUsage struct { | |||
| 29 | + | pos token.Pos | |||
| 30 | + | rule string | |||
| 31 | + | used bool | |||
| 32 | + | filePath string | |||
| 33 | + | } | |||
| 26 | 34 | ||||
| 27 | // collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives. | 35 | // collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives. | ||
| 28 | // A directive suppresses matching diagnostics on its own line and on the | 36 | // A directive suppresses matching diagnostics on its own line and on the | ||
| @@ -46,8 +54,11 @@ func (r runner) collectIgnores(file *ast.File) { | |||||
| 46 | r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule) | 54 | r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule) | ||
| 47 | continue | 55 | continue | ||
| 48 | } | 56 | } | ||
| 49 | - | r.ignores[ignoreKey{file: pos.Filename, line: pos.Line, rule: rule}] = true | 57 | + | usage := &ignoreUsage{pos: comment.Pos(), rule: rule, filePath: r.filePath(file)} |
| 50 | - | r.ignores[ignoreKey{file: pos.Filename, line: pos.Line + 1, rule: rule}] = true | 58 | + | for _, line := range []int{pos.Line, pos.Line + 1} { |
| 59 | + | key := ignoreKey{file: pos.Filename, line: line, rule: rule} | |||
| 60 | + | r.ignores[key] = append(r.ignores[key], usage) | |||
| 61 | + | } | |||
| 51 | } | 62 | } | ||
| 52 | } | 63 | } | ||
| 53 | } | 64 | } | ||
| @@ -58,7 +69,33 @@ func (r runner) isIgnoredInline(pos token.Pos, rule string) bool { | |||||
| 58 | return false | 69 | return false | ||
| 59 | } | 70 | } | ||
| 60 | p := r.pass.Fset.Position(pos) | 71 | p := r.pass.Fset.Position(pos) | ||
| 61 | - | return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}] | 72 | + | usages := r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}] |
| 73 | + | for _, usage := range usages { | |||
| 74 | + | usage.used = true | |||
| 75 | + | } | |||
| 76 | + | return len(usages) > 0 | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func (r runner) reportUnusedIgnores() { | |||
| 80 | + | seen := map[*ignoreUsage]bool{} | |||
| 81 | + | var unused []*ignoreUsage | |||
| 82 | + | for _, usages := range r.ignores { | |||
| 83 | + | for _, usage := range usages { | |||
| 84 | + | if !seen[usage] && !usage.used { | |||
| 85 | + | unused = append(unused, usage) | |||
| 86 | + | } | |||
| 87 | + | seen[usage] = true | |||
| 88 | + | } | |||
| 89 | + | } | |||
| 90 | + | sort.Slice(unused, func(i, j int) bool { | |||
| 91 | + | if unused[i].pos != unused[j].pos { | |||
| 92 | + | return unused[i].pos < unused[j].pos | |||
| 93 | + | } | |||
| 94 | + | return unused[i].rule < unused[j].rule | |||
| 95 | + | }) | |||
| 96 | + | for _, usage := range unused { | |||
| 97 | + | r.report(usage.pos, "unused-ignore-directive", usage.filePath, "hexcheck:ignore %s suppresses no diagnostics", usage.rule) | |||
| 98 | + | } | |||
| 62 | } | 99 | } | ||
| 63 | 100 | ||||
| 64 | func (r runner) reportDirective(pos token.Pos, msg string) { | 101 | func (r runner) reportDirective(pos token.Pos, msg string) { | ||
M analyzer/testdata/src/example.com/project/internal/application/port/leaks.go +1 -1
| @@ -33,5 +33,5 @@ type UnknownDirective interface { | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | type OtherRuleDirective interface { | |
| 36 | - | Raw() sql.Row /* want "no-infra-types-in-ports" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress | |
| 36 | + | Raw() sql.Row /* want "no-infra-types-in-ports" "unused-ignore-directive" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress | |
| 37 | 37 | } | |
| @@ -33,5 +33,5 @@ type UnknownDirective interface { | |||||
| 33 | } | 33 | } | ||
| 34 | 34 | ||||
| 35 | type OtherRuleDirective interface { | 35 | type OtherRuleDirective interface { | ||
| 36 | - | Raw() sql.Row /* want "no-infra-types-in-ports" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress | 36 | + | Raw() sql.Row /* want "no-infra-types-in-ports" "unused-ignore-directive" */ //hexcheck:ignore no-adapter-imports-in-core wrong rule does not suppress |
| 37 | } | 37 | } | ||
A analyzer/testdata/src/example.com/project/internal/domain/noise.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package domain | |
| 2 | + | ||
| 3 | + | import "errors" | |
| 4 | + | ||
| 5 | + | type ObjectID string | |
| 6 | + | ||
| 7 | + | func (id ObjectID) String() string { return string(id) } | |
| 8 | + | func (id ObjectID) IsZero() bool { return id == "" } | |
| 9 | + | func (id ObjectID) IsFull() bool { return len(id) == 40 } | |
| 10 | + | ||
| 11 | + | type Settings struct{ Enabled bool } | |
| 12 | + | ||
| 13 | + | func (s Settings) IsZero() bool { return !s.Enabled } | |
| 14 | + | func DefaultSettings() Settings { return Settings{Enabled: true} } | |
| 15 | + | ||
| 16 | + | type Repo struct { | |
| 17 | + | Name ObjectID | |
| 18 | + | Settings Settings | |
| 19 | + | Version int | |
| 20 | + | } | |
| 21 | + | type Commit struct { | |
| 22 | + | ID ObjectID | |
| 23 | + | Subject string | |
| 24 | + | Size int | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | const MaxObjectBytes = 1024 | |
| 28 | + | const MaxTotalBytes = 4096 | |
| 29 | + | ||
| 30 | + | var ErrNotFound = errors.New("not found") | |
| 31 | + | var ErrDisabled = errors.New("disabled") | |
| 32 | + | ||
| 33 | + | func Allowed(id ObjectID) bool { return id.IsFull() } | |
| 34 | + | ||
| 35 | + | type Limits struct { | |
| 36 | + | MaxLabel string | |
| 37 | + | MaxEnabled bool | |
| 38 | + | Budget int | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | var Budget = 4096 | |
| @@ -0,0 +1,41 @@ | |||||
| 1 | + | package domain | |||
| 2 | + | ||||
| 3 | + | import "errors" | |||
| 4 | + | ||||
| 5 | + | type ObjectID string | |||
| 6 | + | ||||
| 7 | + | func (id ObjectID) String() string { return string(id) } | |||
| 8 | + | func (id ObjectID) IsZero() bool { return id == "" } | |||
| 9 | + | func (id ObjectID) IsFull() bool { return len(id) == 40 } | |||
| 10 | + | ||||
| 11 | + | type Settings struct{ Enabled bool } | |||
| 12 | + | ||||
| 13 | + | func (s Settings) IsZero() bool { return !s.Enabled } | |||
| 14 | + | func DefaultSettings() Settings { return Settings{Enabled: true} } | |||
| 15 | + | ||||
| 16 | + | type Repo struct { | |||
| 17 | + | Name ObjectID | |||
| 18 | + | Settings Settings | |||
| 19 | + | Version int | |||
| 20 | + | } | |||
| 21 | + | type Commit struct { | |||
| 22 | + | ID ObjectID | |||
| 23 | + | Subject string | |||
| 24 | + | Size int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | const MaxObjectBytes = 1024 | |||
| 28 | + | const MaxTotalBytes = 4096 | |||
| 29 | + | ||||
| 30 | + | var ErrNotFound = errors.New("not found") | |||
| 31 | + | var ErrDisabled = errors.New("disabled") | |||
| 32 | + | ||||
| 33 | + | func Allowed(id ObjectID) bool { return id.IsFull() } | |||
| 34 | + | ||||
| 35 | + | type Limits struct { | |||
| 36 | + | MaxLabel string | |||
| 37 | + | MaxEnabled bool | |||
| 38 | + | Budget int | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | var Budget = 4096 | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/capped/cap.go +46 -0
| @@ -0,0 +1,46 @@ | |||
| 1 | + | package capped | |
| 2 | + | ||
| 3 | + | import "example.com/project/internal/domain" | |
| 4 | + | ||
| 5 | + | func first(disabled bool) error { // want "suspicious-business-logic-in-adapter" | |
| 6 | + | if disabled { | |
| 7 | + | return domain.ErrDisabled | |
| 8 | + | } | |
| 9 | + | if !disabled { | |
| 10 | + | return nil | |
| 11 | + | } | |
| 12 | + | return nil | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | //hexcheck:ignore suspicious-business-logic-in-adapter intentional policy at the boundary | |
| 16 | + | func ignoredAfterCap(disabled bool) error { | |
| 17 | + | if disabled { | |
| 18 | + | return domain.ErrDisabled | |
| 19 | + | } | |
| 20 | + | if !disabled { | |
| 21 | + | return nil | |
| 22 | + | } | |
| 23 | + | return nil | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func hiddenOne(disabled bool) error { // want "business-logic-diagnostics-hidden: 2 more suspicious-business-logic-in-adapter diagnostics hidden by heuristics.businessLogicMaxDiagnosticsPerPackage" | |
| 27 | + | if disabled { | |
| 28 | + | return domain.ErrDisabled | |
| 29 | + | } | |
| 30 | + | if !disabled { | |
| 31 | + | return nil | |
| 32 | + | } | |
| 33 | + | return nil | |
| 34 | + | } | |
| 35 | + | func hiddenTwo(disabled bool) error { | |
| 36 | + | if disabled { | |
| 37 | + | return domain.ErrDisabled | |
| 38 | + | } | |
| 39 | + | if !disabled { | |
| 40 | + | return nil | |
| 41 | + | } | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | //hexcheck:ignore suspicious-business-logic-in-adapter obsolete exemption // want "unused-ignore-directive" | |
| 46 | + | func clean() {} | |
| @@ -0,0 +1,46 @@ | |||||
| 1 | + | package capped | |||
| 2 | + | ||||
| 3 | + | import "example.com/project/internal/domain" | |||
| 4 | + | ||||
| 5 | + | func first(disabled bool) error { // want "suspicious-business-logic-in-adapter" | |||
| 6 | + | if disabled { | |||
| 7 | + | return domain.ErrDisabled | |||
| 8 | + | } | |||
| 9 | + | if !disabled { | |||
| 10 | + | return nil | |||
| 11 | + | } | |||
| 12 | + | return nil | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | //hexcheck:ignore suspicious-business-logic-in-adapter intentional policy at the boundary | |||
| 16 | + | func ignoredAfterCap(disabled bool) error { | |||
| 17 | + | if disabled { | |||
| 18 | + | return domain.ErrDisabled | |||
| 19 | + | } | |||
| 20 | + | if !disabled { | |||
| 21 | + | return nil | |||
| 22 | + | } | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func hiddenOne(disabled bool) error { // want "business-logic-diagnostics-hidden: 2 more suspicious-business-logic-in-adapter diagnostics hidden by heuristics.businessLogicMaxDiagnosticsPerPackage" | |||
| 27 | + | if disabled { | |||
| 28 | + | return domain.ErrDisabled | |||
| 29 | + | } | |||
| 30 | + | if !disabled { | |||
| 31 | + | return nil | |||
| 32 | + | } | |||
| 33 | + | return nil | |||
| 34 | + | } | |||
| 35 | + | func hiddenTwo(disabled bool) error { | |||
| 36 | + | if disabled { | |||
| 37 | + | return domain.ErrDisabled | |||
| 38 | + | } | |||
| 39 | + | if !disabled { | |||
| 40 | + | return nil | |||
| 41 | + | } | |||
| 42 | + | return nil | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | //hexcheck:ignore suspicious-business-logic-in-adapter obsolete exemption // want "unused-ignore-directive" | |||
| 46 | + | func clean() {} | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/noise.go +132 -0
| @@ -0,0 +1,132 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "example.com/project/internal/domain" | |
| 6 | + | "strings" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | type storedRepo struct { | |
| 10 | + | Name string | |
| 11 | + | Version int | |
| 12 | + | Enabled bool | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func decodeRepo(raw storedRepo) domain.Repo { | |
| 16 | + | var repo domain.Repo | |
| 17 | + | repo.Name = domain.ObjectID(raw.Name) | |
| 18 | + | repo.Version = raw.Version | |
| 19 | + | repo.Settings.Enabled = raw.Enabled | |
| 20 | + | if raw.Name == "" { | |
| 21 | + | repo.Name = "unknown" | |
| 22 | + | } | |
| 23 | + | return repo | |
| 24 | + | } | |
| 25 | + | func parseCommit(output string, id domain.ObjectID) domain.Commit { | |
| 26 | + | var c domain.Commit | |
| 27 | + | for _, line := range strings.Split(output, "\n") { | |
| 28 | + | if strings.HasPrefix(line, "subject ") { | |
| 29 | + | c.Subject = strings.TrimPrefix(line, "subject ") | |
| 30 | + | } | |
| 31 | + | if line == id.String() { | |
| 32 | + | c.ID = id | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | return c | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | var libraryNotFound = errors.New("missing") | |
| 39 | + | ||
| 40 | + | func translateError(err error) error { | |
| 41 | + | if errors.Is(err, libraryNotFound) { | |
| 42 | + | return domain.ErrNotFound | |
| 43 | + | } | |
| 44 | + | return err | |
| 45 | + | } | |
| 46 | + | func Update(repo domain.Repo, version int, put func(string, domain.Repo) error) error { | |
| 47 | + | if repo.Version != version { | |
| 48 | + | return domain.ErrNotFound | |
| 49 | + | } | |
| 50 | + | if err := put(repo.Name.String(), repo); err != nil { | |
| 51 | + | return translateError(err) | |
| 52 | + | } | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func resolveRevision(rev string, id domain.ObjectID, lookup func(string) bool) string { // want "suspicious-business-logic-in-adapter" | |
| 57 | + | if rev == "HEAD" { | |
| 58 | + | return "head" | |
| 59 | + | } | |
| 60 | + | if id.IsFull() { | |
| 61 | + | if lookup(id.String()) { | |
| 62 | + | return "object" | |
| 63 | + | } | |
| 64 | + | if lookup("heads/" + rev) { | |
| 65 | + | return "branch" | |
| 66 | + | } | |
| 67 | + | if lookup("tags/" + rev) { | |
| 68 | + | return "tag" | |
| 69 | + | } | |
| 70 | + | } | |
| 71 | + | if strings.HasPrefix(rev, "refs/") { | |
| 72 | + | return rev | |
| 73 | + | } | |
| 74 | + | if !id.IsZero() && lookup(rev) { | |
| 75 | + | return "abbrev" | |
| 76 | + | } | |
| 77 | + | return "" | |
| 78 | + | } | |
| 79 | + | func ReadCommitObjects(items []domain.Commit) []domain.Commit { // want "suspicious-business-logic-in-adapter" | |
| 80 | + | var out []domain.Commit | |
| 81 | + | total := 0 | |
| 82 | + | for _, c := range items { | |
| 83 | + | if c.Size > domain.MaxObjectBytes { | |
| 84 | + | continue | |
| 85 | + | } | |
| 86 | + | if total+c.Size > domain.MaxTotalBytes { | |
| 87 | + | break | |
| 88 | + | } | |
| 89 | + | total += c.Size | |
| 90 | + | out = append(out, c) | |
| 91 | + | } | |
| 92 | + | return out | |
| 93 | + | } | |
| 94 | + | func commitsAhead(source, target []domain.Commit, tip string) ([]domain.Commit, bool) { // want "suspicious-business-logic-in-adapter" | |
| 95 | + | seen := map[string]bool{} | |
| 96 | + | for _, c := range target { | |
| 97 | + | seen[c.ID.String()] = true | |
| 98 | + | } | |
| 99 | + | var ahead []domain.Commit | |
| 100 | + | for _, c := range source { | |
| 101 | + | if seen[c.ID.String()] { | |
| 102 | + | return ahead, c.ID.String() != tip | |
| 103 | + | } | |
| 104 | + | if len(ahead) > 100 { | |
| 105 | + | break | |
| 106 | + | } | |
| 107 | + | ahead = append(ahead, c) | |
| 108 | + | } | |
| 109 | + | return ahead, true | |
| 110 | + | } | |
| 111 | + | func repoRecord(get func() (domain.Repo, error)) (domain.Repo, error) { // want "suspicious-business-logic-in-adapter" | |
| 112 | + | repo, err := get() | |
| 113 | + | if err != nil { | |
| 114 | + | return domain.Repo{}, err | |
| 115 | + | } | |
| 116 | + | if repo.Settings.IsZero() { | |
| 117 | + | repo.Settings = domain.DefaultSettings() | |
| 118 | + | } | |
| 119 | + | return repo, nil | |
| 120 | + | } | |
| 121 | + | func CreateRun(disabled bool, save func() error) error { // want "suspicious-business-logic-in-adapter" | |
| 122 | + | if disabled { | |
| 123 | + | return domain.ErrDisabled | |
| 124 | + | } | |
| 125 | + | if err := save(); err != nil { | |
| 126 | + | return err | |
| 127 | + | } | |
| 128 | + | return nil | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | //hexcheck:ignore suspicious-business-logic-in-adapter decoding has no policy // want "unused-ignore-directive" | |
| 132 | + | func cleanDecode(raw storedRepo) domain.Repo { return decodeRepo(raw) } | |
| @@ -0,0 +1,132 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "errors" | |||
| 5 | + | "example.com/project/internal/domain" | |||
| 6 | + | "strings" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | type storedRepo struct { | |||
| 10 | + | Name string | |||
| 11 | + | Version int | |||
| 12 | + | Enabled bool | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func decodeRepo(raw storedRepo) domain.Repo { | |||
| 16 | + | var repo domain.Repo | |||
| 17 | + | repo.Name = domain.ObjectID(raw.Name) | |||
| 18 | + | repo.Version = raw.Version | |||
| 19 | + | repo.Settings.Enabled = raw.Enabled | |||
| 20 | + | if raw.Name == "" { | |||
| 21 | + | repo.Name = "unknown" | |||
| 22 | + | } | |||
| 23 | + | return repo | |||
| 24 | + | } | |||
| 25 | + | func parseCommit(output string, id domain.ObjectID) domain.Commit { | |||
| 26 | + | var c domain.Commit | |||
| 27 | + | for _, line := range strings.Split(output, "\n") { | |||
| 28 | + | if strings.HasPrefix(line, "subject ") { | |||
| 29 | + | c.Subject = strings.TrimPrefix(line, "subject ") | |||
| 30 | + | } | |||
| 31 | + | if line == id.String() { | |||
| 32 | + | c.ID = id | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | return c | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | var libraryNotFound = errors.New("missing") | |||
| 39 | + | ||||
| 40 | + | func translateError(err error) error { | |||
| 41 | + | if errors.Is(err, libraryNotFound) { | |||
| 42 | + | return domain.ErrNotFound | |||
| 43 | + | } | |||
| 44 | + | return err | |||
| 45 | + | } | |||
| 46 | + | func Update(repo domain.Repo, version int, put func(string, domain.Repo) error) error { | |||
| 47 | + | if repo.Version != version { | |||
| 48 | + | return domain.ErrNotFound | |||
| 49 | + | } | |||
| 50 | + | if err := put(repo.Name.String(), repo); err != nil { | |||
| 51 | + | return translateError(err) | |||
| 52 | + | } | |||
| 53 | + | return nil | |||
| 54 | + | } | |||
| 55 | + | ||||
| 56 | + | func resolveRevision(rev string, id domain.ObjectID, lookup func(string) bool) string { // want "suspicious-business-logic-in-adapter" | |||
| 57 | + | if rev == "HEAD" { | |||
| 58 | + | return "head" | |||
| 59 | + | } | |||
| 60 | + | if id.IsFull() { | |||
| 61 | + | if lookup(id.String()) { | |||
| 62 | + | return "object" | |||
| 63 | + | } | |||
| 64 | + | if lookup("heads/" + rev) { | |||
| 65 | + | return "branch" | |||
| 66 | + | } | |||
| 67 | + | if lookup("tags/" + rev) { | |||
| 68 | + | return "tag" | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 71 | + | if strings.HasPrefix(rev, "refs/") { | |||
| 72 | + | return rev | |||
| 73 | + | } | |||
| 74 | + | if !id.IsZero() && lookup(rev) { | |||
| 75 | + | return "abbrev" | |||
| 76 | + | } | |||
| 77 | + | return "" | |||
| 78 | + | } | |||
| 79 | + | func ReadCommitObjects(items []domain.Commit) []domain.Commit { // want "suspicious-business-logic-in-adapter" | |||
| 80 | + | var out []domain.Commit | |||
| 81 | + | total := 0 | |||
| 82 | + | for _, c := range items { | |||
| 83 | + | if c.Size > domain.MaxObjectBytes { | |||
| 84 | + | continue | |||
| 85 | + | } | |||
| 86 | + | if total+c.Size > domain.MaxTotalBytes { | |||
| 87 | + | break | |||
| 88 | + | } | |||
| 89 | + | total += c.Size | |||
| 90 | + | out = append(out, c) | |||
| 91 | + | } | |||
| 92 | + | return out | |||
| 93 | + | } | |||
| 94 | + | func commitsAhead(source, target []domain.Commit, tip string) ([]domain.Commit, bool) { // want "suspicious-business-logic-in-adapter" | |||
| 95 | + | seen := map[string]bool{} | |||
| 96 | + | for _, c := range target { | |||
| 97 | + | seen[c.ID.String()] = true | |||
| 98 | + | } | |||
| 99 | + | var ahead []domain.Commit | |||
| 100 | + | for _, c := range source { | |||
| 101 | + | if seen[c.ID.String()] { | |||
| 102 | + | return ahead, c.ID.String() != tip | |||
| 103 | + | } | |||
| 104 | + | if len(ahead) > 100 { | |||
| 105 | + | break | |||
| 106 | + | } | |||
| 107 | + | ahead = append(ahead, c) | |||
| 108 | + | } | |||
| 109 | + | return ahead, true | |||
| 110 | + | } | |||
| 111 | + | func repoRecord(get func() (domain.Repo, error)) (domain.Repo, error) { // want "suspicious-business-logic-in-adapter" | |||
| 112 | + | repo, err := get() | |||
| 113 | + | if err != nil { | |||
| 114 | + | return domain.Repo{}, err | |||
| 115 | + | } | |||
| 116 | + | if repo.Settings.IsZero() { | |||
| 117 | + | repo.Settings = domain.DefaultSettings() | |||
| 118 | + | } | |||
| 119 | + | return repo, nil | |||
| 120 | + | } | |||
| 121 | + | func CreateRun(disabled bool, save func() error) error { // want "suspicious-business-logic-in-adapter" | |||
| 122 | + | if disabled { | |||
| 123 | + | return domain.ErrDisabled | |||
| 124 | + | } | |||
| 125 | + | if err := save(); err != nil { | |||
| 126 | + | return err | |||
| 127 | + | } | |||
| 128 | + | return nil | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | //hexcheck:ignore suspicious-business-logic-in-adapter decoding has no policy // want "unused-ignore-directive" | |||
| 132 | + | func cleanDecode(raw storedRepo) domain.Repo { return decodeRepo(raw) } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/http/review.go +92 -0
| @@ -0,0 +1,92 @@ | |||
| 1 | + | package http | |
| 2 | + | ||
| 3 | + | import "example.com/project/internal/domain" | |
| 4 | + | ||
| 5 | + | func ResolveMethod(id domain.ObjectID) bool { // want "suspicious-business-logic-in-adapter" | |
| 6 | + | if id.IsFull() { | |
| 7 | + | return true | |
| 8 | + | } | |
| 9 | + | if id.String() == "" { | |
| 10 | + | return false | |
| 11 | + | } | |
| 12 | + | return false | |
| 13 | + | } | |
| 14 | + | func ResolveFree(id domain.ObjectID) bool { // want "suspicious-business-logic-in-adapter" | |
| 15 | + | if domain.Allowed(id) { | |
| 16 | + | return true | |
| 17 | + | } | |
| 18 | + | if id.String() == "" { | |
| 19 | + | return false | |
| 20 | + | } | |
| 21 | + | return false | |
| 22 | + | } | |
| 23 | + | func ResolveBoolSet(id domain.ObjectID, seen map[string]bool) bool { // want "suspicious-business-logic-in-adapter" | |
| 24 | + | if _, ok := seen[id.String()]; ok { | |
| 25 | + | return true | |
| 26 | + | } | |
| 27 | + | if id.String() == "" { | |
| 28 | + | return false | |
| 29 | + | } | |
| 30 | + | return false | |
| 31 | + | } | |
| 32 | + | func ResolveStructSet(id domain.ObjectID, seen map[string]struct{}) bool { // want "suspicious-business-logic-in-adapter" | |
| 33 | + | if _, ok := seen[id.String()]; !ok { | |
| 34 | + | return false | |
| 35 | + | } | |
| 36 | + | if id.String() == "" { | |
| 37 | + | return false | |
| 38 | + | } | |
| 39 | + | return true | |
| 40 | + | } | |
| 41 | + | func ResolveUnusedMembership(id domain.ObjectID, seen map[string]struct{}, other bool) bool { | |
| 42 | + | if _, ok := seen[id.String()]; other { | |
| 43 | + | _ = ok | |
| 44 | + | return true | |
| 45 | + | } | |
| 46 | + | return false | |
| 47 | + | } | |
| 48 | + | func ResolveStringMax(l domain.Limits) bool { | |
| 49 | + | if l.MaxLabel == "" { | |
| 50 | + | return false | |
| 51 | + | } | |
| 52 | + | if l.MaxLabel > "a" { | |
| 53 | + | return true | |
| 54 | + | } | |
| 55 | + | return false | |
| 56 | + | } | |
| 57 | + | func ResolveBoolMax(l domain.Limits) bool { | |
| 58 | + | if l.MaxEnabled == true { | |
| 59 | + | return false | |
| 60 | + | } | |
| 61 | + | if !l.MaxEnabled { | |
| 62 | + | return true | |
| 63 | + | } | |
| 64 | + | return false | |
| 65 | + | } | |
| 66 | + | func ResolveNumericEquality(l domain.Limits) bool { | |
| 67 | + | if l.Budget == 0 { | |
| 68 | + | return false | |
| 69 | + | } | |
| 70 | + | if l.Budget != 1 { | |
| 71 | + | return true | |
| 72 | + | } | |
| 73 | + | return false | |
| 74 | + | } | |
| 75 | + | func ResolveNumericField(l domain.Limits) bool { // want "suspicious-business-logic-in-adapter" | |
| 76 | + | if l.Budget > 0 { | |
| 77 | + | return true | |
| 78 | + | } | |
| 79 | + | if l.Budget < 1 { | |
| 80 | + | return false | |
| 81 | + | } | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | func ResolveNumericVar(n int) bool { // want "suspicious-business-logic-in-adapter" | |
| 85 | + | if n > domain.Budget { | |
| 86 | + | return true | |
| 87 | + | } | |
| 88 | + | if n < 1 { | |
| 89 | + | return false | |
| 90 | + | } | |
| 91 | + | return false | |
| 92 | + | } | |
| @@ -0,0 +1,92 @@ | |||||
| 1 | + | package http | |||
| 2 | + | ||||
| 3 | + | import "example.com/project/internal/domain" | |||
| 4 | + | ||||
| 5 | + | func ResolveMethod(id domain.ObjectID) bool { // want "suspicious-business-logic-in-adapter" | |||
| 6 | + | if id.IsFull() { | |||
| 7 | + | return true | |||
| 8 | + | } | |||
| 9 | + | if id.String() == "" { | |||
| 10 | + | return false | |||
| 11 | + | } | |||
| 12 | + | return false | |||
| 13 | + | } | |||
| 14 | + | func ResolveFree(id domain.ObjectID) bool { // want "suspicious-business-logic-in-adapter" | |||
| 15 | + | if domain.Allowed(id) { | |||
| 16 | + | return true | |||
| 17 | + | } | |||
| 18 | + | if id.String() == "" { | |||
| 19 | + | return false | |||
| 20 | + | } | |||
| 21 | + | return false | |||
| 22 | + | } | |||
| 23 | + | func ResolveBoolSet(id domain.ObjectID, seen map[string]bool) bool { // want "suspicious-business-logic-in-adapter" | |||
| 24 | + | if _, ok := seen[id.String()]; ok { | |||
| 25 | + | return true | |||
| 26 | + | } | |||
| 27 | + | if id.String() == "" { | |||
| 28 | + | return false | |||
| 29 | + | } | |||
| 30 | + | return false | |||
| 31 | + | } | |||
| 32 | + | func ResolveStructSet(id domain.ObjectID, seen map[string]struct{}) bool { // want "suspicious-business-logic-in-adapter" | |||
| 33 | + | if _, ok := seen[id.String()]; !ok { | |||
| 34 | + | return false | |||
| 35 | + | } | |||
| 36 | + | if id.String() == "" { | |||
| 37 | + | return false | |||
| 38 | + | } | |||
| 39 | + | return true | |||
| 40 | + | } | |||
| 41 | + | func ResolveUnusedMembership(id domain.ObjectID, seen map[string]struct{}, other bool) bool { | |||
| 42 | + | if _, ok := seen[id.String()]; other { | |||
| 43 | + | _ = ok | |||
| 44 | + | return true | |||
| 45 | + | } | |||
| 46 | + | return false | |||
| 47 | + | } | |||
| 48 | + | func ResolveStringMax(l domain.Limits) bool { | |||
| 49 | + | if l.MaxLabel == "" { | |||
| 50 | + | return false | |||
| 51 | + | } | |||
| 52 | + | if l.MaxLabel > "a" { | |||
| 53 | + | return true | |||
| 54 | + | } | |||
| 55 | + | return false | |||
| 56 | + | } | |||
| 57 | + | func ResolveBoolMax(l domain.Limits) bool { | |||
| 58 | + | if l.MaxEnabled == true { | |||
| 59 | + | return false | |||
| 60 | + | } | |||
| 61 | + | if !l.MaxEnabled { | |||
| 62 | + | return true | |||
| 63 | + | } | |||
| 64 | + | return false | |||
| 65 | + | } | |||
| 66 | + | func ResolveNumericEquality(l domain.Limits) bool { | |||
| 67 | + | if l.Budget == 0 { | |||
| 68 | + | return false | |||
| 69 | + | } | |||
| 70 | + | if l.Budget != 1 { | |||
| 71 | + | return true | |||
| 72 | + | } | |||
| 73 | + | return false | |||
| 74 | + | } | |||
| 75 | + | func ResolveNumericField(l domain.Limits) bool { // want "suspicious-business-logic-in-adapter" | |||
| 76 | + | if l.Budget > 0 { | |||
| 77 | + | return true | |||
| 78 | + | } | |||
| 79 | + | if l.Budget < 1 { | |||
| 80 | + | return false | |||
| 81 | + | } | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | func ResolveNumericVar(n int) bool { // want "suspicious-business-logic-in-adapter" | |||
| 85 | + | if n > domain.Budget { | |||
| 86 | + | return true | |||
| 87 | + | } | |||
| 88 | + | if n < 1 { | |||
| 89 | + | return false | |||
| 90 | + | } | |||
| 91 | + | return false | |||
| 92 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/variants/variant.go +14 -0
| @@ -0,0 +1,14 @@ | |||
| 1 | + | package variants | |
| 2 | + | ||
| 3 | + | import "example.com/project/internal/domain" | |
| 4 | + | ||
| 5 | + | //hexcheck:ignore suspicious-business-logic-in-adapter intentional boundary policy | |
| 6 | + | func Resolve(id domain.ObjectID) bool { | |
| 7 | + | if domain.Allowed(id) { | |
| 8 | + | return true | |
| 9 | + | } | |
| 10 | + | if id.String() == "" { | |
| 11 | + | return false | |
| 12 | + | } | |
| 13 | + | return false | |
| 14 | + | } | |
| @@ -0,0 +1,14 @@ | |||||
| 1 | + | package variants | |||
| 2 | + | ||||
| 3 | + | import "example.com/project/internal/domain" | |||
| 4 | + | ||||
| 5 | + | //hexcheck:ignore suspicious-business-logic-in-adapter intentional boundary policy | |||
| 6 | + | func Resolve(id domain.ObjectID) bool { | |||
| 7 | + | if domain.Allowed(id) { | |||
| 8 | + | return true | |||
| 9 | + | } | |||
| 10 | + | if id.String() == "" { | |||
| 11 | + | return false | |||
| 12 | + | } | |||
| 13 | + | return false | |||
| 14 | + | } | |||
A analyzer/testdata/src/example.com/project/internal/infrastructure/variants/variant_test.go +3 -0
| @@ -0,0 +1,3 @@ | |||
| 1 | + | package variants | |
| 2 | + | ||
| 3 | + | func ExampleResolve() {} | |
| @@ -0,0 +1,3 @@ | |||||
| 1 | + | package variants | |||
| 2 | + | ||||
| 3 | + | func ExampleResolve() {} | |||
M config/config.go +2 -0
| @@ -197,6 +197,8 @@ func DefaultRuleSeverities() map[string]Severity { | |||
| 197 | 197 | "no-infra-types-in-ports": SeverityError, | |
| 198 | 198 | "no-adapter-to-adapter-imports": SeverityWarn, | |
| 199 | 199 | "suspicious-business-logic-in-adapter": SeverityWarn, | |
| 200 | + | "unused-ignore-directive": SeverityWarn, | |
| 201 | + | "business-logic-diagnostics-hidden": SeverityWarn, | |
| 200 | 202 | "no-local-fakes-for-ports": SeverityWarn, | |
| 201 | 203 | "missing-generated-mock-for-port": SeverityWarn, | |
| 202 | 204 | "prefer-generated-mocks": SeverityWarn, | |
| @@ -197,6 +197,8 @@ func DefaultRuleSeverities() map[string]Severity { | |||||
| 197 | "no-infra-types-in-ports": SeverityError, | 197 | "no-infra-types-in-ports": SeverityError, | ||
| 198 | "no-adapter-to-adapter-imports": SeverityWarn, | 198 | "no-adapter-to-adapter-imports": SeverityWarn, | ||
| 199 | "suspicious-business-logic-in-adapter": SeverityWarn, | 199 | "suspicious-business-logic-in-adapter": SeverityWarn, | ||
| 200 | + | "unused-ignore-directive": SeverityWarn, | |||
| 201 | + | "business-logic-diagnostics-hidden": SeverityWarn, | |||
| 200 | "no-local-fakes-for-ports": SeverityWarn, | 202 | "no-local-fakes-for-ports": SeverityWarn, | ||
| 201 | "missing-generated-mock-for-port": SeverityWarn, | 203 | "missing-generated-mock-for-port": SeverityWarn, | ||
| 202 | "prefer-generated-mocks": SeverityWarn, | 204 | "prefer-generated-mocks": SeverityWarn, | ||