detached 556f10f

Merge 0c7aeaa451dabd5179638660a3c62a65ec7e305f into 1e04dbb2213264bf2fa33c84b524dc693eff001c

commit
556f10fa5a0b00a64a12266a3d29581a2aedad17
author
ermite · · 2026-10-19 13:25 UTC
merge of
1e04dbb + 0c7aeaa · diff against the first parent
signature
unsigned
16 files · +771 -31
M CHANGELOG.md +11 -0
@@ -1,5 +1,16 @@
11 # Changelog
22
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+
314 ## v0.3.1
415
516 ### Fixed
@@ -1,5 +1,16 @@
1 # Changelog1 # 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.114 ## v0.3.1
4 15
5 ### Fixed16 ### Fixed
M README.md +5 -1
@@ -115,12 +115,16 @@ Business-logic diagnostics include a confidence level. `audit` mode reports find
115115 | `no-infra-types-in-ports` | error | port interface method exposes an adapter or framework type |
116116 | `no-adapter-to-adapter-imports` | warn | adapter imports another adapter component |
117117 | `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 |
118120 | `no-local-fakes-for-ports` | warn | test defines a fake for a port that has a generated mock |
119121 | `missing-generated-mock-for-port` | warn | port interface has no generated mock |
120122 | `prefer-generated-mocks` | warn | usecase/core test imports a concrete adapter |
121123
122124 Type-leak rules look through pointers, slices, maps, channels, function signatures, inline structs, aliases, and generic type arguments.
123125
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+
124128 The config is strict: unknown keys and unknown rule names are errors.
125129
126130 ## Suppressing a finding
@@ -134,7 +138,7 @@ type Rows interface {
134138 }
135139 ```
136140
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.
138142
139143 ## Standalone CLI
140144
@@ -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 finding130 ## 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 CLI143 ## Standalone CLI
140 144
M analyzer/analyzer.go +30 -7
@@ -172,11 +172,19 @@ func firstNonEmpty(values ...string) string {
172172 }
173173
174174 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
180188 }
181189
182190 func (r runner) run() {
@@ -186,7 +194,7 @@ func (r runner) run() {
186194 return
187195 }
188196 r.ignores = ignoreIndex{}
189- businessDiagnostics := 0
197+ r.businessCap = &businessDiagnosticCap{}
190198 for _, file := range r.pass.Files {
191199 filePath := r.filePath(file)
192200 if isGeneratedFile(file) {
@@ -196,9 +204,13 @@ func (r runner) run() {
196204 r.checkImports(file, filePath, current)
197205 r.checkTypeLeaks(file, filePath, current)
198206 r.checkMissingMocks(file, filePath, current)
199- r.checkBusinessLogic(file, filePath, current, &businessDiagnostics)
207+ r.checkBusinessLogic(file, filePath, current)
200208 r.checkMocks(file, filePath, current)
201209 }
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()
202214 }
203215
204216 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
348360 if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
349361 return false
350362 }
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+ }
351374 r.pass.Report(analysis.Diagnostic{
352375 Pos: pos,
353376 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.Pass175+ pass *analysis.Pass
176- cfg *config.Config176+ cfg *config.Config
177- modulePath string177+ modulePath string
178- state *state178+ state *state
179- ignores ignoreIndex179+ 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 return194 return
187 }195 }
188 r.ignores = ignoreIndex{}196 r.ignores = ignoreIndex{}
189- businessDiagnostics := 0197+ 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 false361 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 {
6464 BusinessLogicMinStrongSignals: testIntPtr(2),
6565 BusinessLogicMinWeakSignals: testIntPtr(2),
6666 BusinessLogicMaxFunctionNodes: testIntPtr(2000),
67- BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(10),
67+ BusinessLogicMaxDiagnosticsPerPackage: testIntPtr(100),
6868 BusinessLogicMode: "audit",
6969 BusinessLogicMinConfidence: "medium",
7070 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 {
2424 strong bool
2525 }
2626
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) {
2828 if current.Role != config.RoleAdapter && current.Role != config.RoleEntrypoint {
2929 return
3030 }
@@ -37,12 +37,7 @@ func (r runner) checkBusinessLogic(file *ast.File, filePath string, current conf
3737 if !ok || fn.Body == nil {
3838 continue
3939 }
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)
4641 }
4742 }
4843
@@ -72,6 +67,18 @@ func (r runner) collectBusinessFunctionFacts(fn *ast.FuncDecl, fileRole config.R
7267 facts.imports[strings.Trim(imp.Path.Value, "\"")] = true
7368 }
7469 }
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()
7582 ast.Inspect(fn.Body, facts.inspect)
7683 return facts
7784 }
@@ -86,6 +93,9 @@ type businessFunctionFacts struct {
8693 keyword bool
8794 domainTypeUse bool
8895 domainMutation bool
96+ domainDecision bool
97+ zeroDecision bool
98+ guardedDomainError bool
8999 domainMethodCall bool
90100 domainConstructorCall bool
91101 domainErrorReturn bool
@@ -93,27 +103,36 @@ type businessFunctionFacts struct {
93103 usecaseCalls map[string]bool
94104 imports map[string]bool
95105 usesTechnicalPackage bool
106+ constructedLocals map[types.Object]bool
96107 }
97108
98109 func (f *businessFunctionFacts) inspect(n ast.Node) bool {
99110 if n == nil {
100111 return true
101112 }
102- f.nodeCount++
103- if f.nodeCount > *f.runner.cfg.Heuristics.BusinessLogicMaxFunctionNodes {
104- return false
105- }
106113
107114 if f.exprUsesRole(exprFromNode(n), config.RoleCore) || f.exprUsesRole(exprFromNode(n), config.RoleUsecase) {
108115 f.domainTypeUse = true
109116 }
110117
111118 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:
113132 f.branchCount++
114133 case *ast.AssignStmt:
115134 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) {
117136 f.domainMutation = true
118137 }
119138 }
@@ -125,7 +144,7 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
125144 }
126145 case *ast.ReturnStmt:
127146 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) {
129148 f.domainErrorReturn = true
130149 }
131150 }
@@ -133,6 +152,219 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
133152 return true
134153 }
135154
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+
136368 func (f *businessFunctionFacts) inspectCall(call *ast.CallExpr) {
137369 f.inspectTechnicalCall(call)
138370 calleeType := f.runner.pass.TypesInfo.TypeOf(call.Fun)
@@ -258,11 +490,20 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog
258490 }
259491 }
260492
493+ if f.zeroDecision && f.domainMutation {
494+ f.domainDecision = true
495+ }
261496 if f.domainMutation {
262- strong = append(strong, businessLogicSignal{kind: "domain mutation", strong: true})
497+ weak = append(weak, businessLogicSignal{kind: "domain mutation"})
263498 }
264499 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})
266507 }
267508 if f.keyword && len(f.usecaseCalls) >= 2 {
268509 strong = append(strong, businessLogicSignal{kind: "business keyword with multiple port/usecase collaborators", strong: true})
@@ -270,7 +511,7 @@ func (f *businessFunctionFacts) classify() ([]businessLogicSignal, []businessLog
270511 if f.keyword && f.branchCount >= 3 && f.policyConstant && policyKeyword(f.fn.Name.Name) {
271512 strong = append(strong, businessLogicSignal{kind: "business keyword with policy constants and branching", strong: true})
272513 }
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")))) {
274515 strong = append(strong, businessLogicSignal{kind: "business keyword with domain context", strong: true})
275516 }
276517
@@ -24,7 +24,7 @@ type businessLogicSignal struct {
24 strong bool24 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 return29 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 continue38 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, "\"")] = true67 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 facts83 return facts
77 }84 }
@@ -86,6 +93,9 @@ type businessFunctionFacts struct {
86 keyword bool93 keyword bool
87 domainTypeUse bool94 domainTypeUse bool
88 domainMutation bool95 domainMutation bool
96+ domainDecision bool
97+ zeroDecision bool
98+ guardedDomainError bool
89 domainMethodCall bool99 domainMethodCall bool
90 domainConstructorCall bool100 domainConstructorCall bool
91 domainErrorReturn bool101 domainErrorReturn bool
@@ -93,27 +103,36 @@ type businessFunctionFacts struct {
93 usecaseCalls map[string]bool103 usecaseCalls map[string]bool
94 imports map[string]bool104 imports map[string]bool
95 usesTechnicalPackage bool105 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 true111 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 = true115 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 = true136 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 = true148 f.domainErrorReturn = true
130 }149 }
131 }150 }
@@ -133,6 +152,219 @@ func (f *businessFunctionFacts) inspect(n ast.Node) bool {
133 return true152 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
22
33 import (
44 "go/ast"
5+ "go/parser"
6+ "go/token"
57 "go/types"
68 "testing"
79
@@ -9,6 +11,63 @@ import (
911 "golang.org/x/tools/go/analysis"
1012 )
1113
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+
1271 func TestCollectBusinessFunctionFactsInspectsFunctionBody(t *testing.T) {
1372 fn := &ast.FuncDecl{
1473 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 (
55 "strings"
66 "testing"
77
8+ "github.com/bnema/hexcheck/config"
9+
810 "golang.org/x/tools/go/analysis/analysistest"
911 )
1012
@@ -37,6 +39,39 @@ func TestBusinessLogicGoldenCases(t *testing.T) {
3739 }
3840 }
3941
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+
4075 func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool {
4176 for file, rules := range rulesByFile {
4277 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
33 import (
44 "go/ast"
55 "go/token"
6+ "sort"
67 "strings"
78
89 "github.com/bnema/hexcheck/config"
@@ -22,7 +23,14 @@ type ignoreKey struct {
2223 }
2324
2425 // 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+}
2634
2735 // collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives.
2836 // A directive suppresses matching diagnostics on its own line and on the
@@ -46,8 +54,11 @@ func (r runner) collectIgnores(file *ast.File) {
4654 r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule)
4755 continue
4856 }
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+ }
5162 }
5263 }
5364 }
@@ -58,7 +69,33 @@ func (r runner) isIgnoredInline(pos token.Pos, rule string) bool {
5869 return false
5970 }
6071 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+ }
6299 }
63100
64101 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]bool26+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 the36 // 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 continue55 continue
48 }56 }
49- r.ignores[ignoreKey{file: pos.Filename, line: pos.Line, rule: rule}] = true57+ usage := &ignoreUsage{pos: comment.Pos(), rule: rule, filePath: r.filePath(file)}
50- r.ignores[ignoreKey{file: pos.Filename, line: pos.Line + 1, rule: rule}] = true58+ 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 false69 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 {
3333 }
3434
3535 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
3737 }
@@ -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 suppress36+ 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 {
197197 "no-infra-types-in-ports": SeverityError,
198198 "no-adapter-to-adapter-imports": SeverityWarn,
199199 "suspicious-business-logic-in-adapter": SeverityWarn,
200+ "unused-ignore-directive": SeverityWarn,
201+ "business-logic-diagnostics-hidden": SeverityWarn,
200202 "no-local-fakes-for-ports": SeverityWarn,
201203 "missing-generated-mock-for-port": SeverityWarn,
202204 "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,

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close