detached 0cdd9c7

feat(analyzer): add missing boundary rules, strict config and inline ignores

- add no-usecase-imports-in-core, no-infra-imports-in-ports and
  no-entrypoint-imports-in-adapter; usecase rule also covers entrypoints
- reject unknown config keys and unknown rule names
- tag diagnostics with severity and rule category, expose severities as result
- inspect channels, signatures, structs, aliases and type args for type leaks
- support //hexcheck:ignore <rules> <reason> directives
- load config and mock index once per analyzer run instead of per package/process
commit
0cdd9c756717ee2ab5cd2f5a2de6a592dbf0badd
author
brice · · 2026-10-08 05:44 UTC
parent
92ac699
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
14 files · +329 -72
M analyzer/analyzer.go +110 -22
@@ -1,13 +1,16 @@
11 package analyzer
22
33 import (
4+ "fmt"
45 "go/ast"
56 "go/token"
67 "go/types"
78 "os"
89 "path/filepath"
10+ "reflect"
911 "strconv"
1012 "strings"
13+ "sync"
1114
1215 "github.com/bnema/hexcheck/config"
1316 "github.com/bnema/hexcheck/internal/glob"
@@ -23,26 +26,60 @@ type Options struct {
2326 ModulePath string
2427 }
2528
29+// Result is returned for each analyzed package. Drivers use it to map a
30+// diagnostic Category (the rule name) to its configured severity.
31+type Result struct {
32+ Severities map[string]config.Severity
33+}
34+
35+// Severity returns the configured severity of a diagnostic category.
36+func (r *Result) Severity(rule string) config.Severity {
37+ if r == nil {
38+ return config.SeverityOff
39+ }
40+ return r.Severities[rule]
41+}
42+
43+// state is shared by every package analyzed by one Analyzer instance, so the
44+// config and mock index are loaded once per run instead of once per package.
45+type state struct {
46+ once sync.Once
47+ cfg *config.Config
48+ modulePath string
49+ err error
50+ mockOnce sync.Once
51+ mocks map[string]bool
52+}
53+
2654 func New(opts Options) *analysis.Analyzer {
2755 var configPathFlag string
2856 var rootFlag string
2957 var modulePathFlag string
58+ st := &state{}
3059
3160 a := &analysis.Analyzer{
32- Name: Name,
33- Doc: "checks hexagonal architecture boundaries",
61+ Name: Name,
62+ Doc: "checks hexagonal architecture boundaries",
63+ ResultType: reflect.TypeFor[*Result](),
3464 Run: func(pass *analysis.Pass) (any, error) {
35- cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
36- if err != nil {
37- return nil, err
38- }
39- modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
40- if modulePath == "" {
41- modulePath = discoverModulePath(root)
65+ st.once.Do(func() {
66+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
67+ if err != nil {
68+ st.err = err
69+ return
70+ }
71+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
72+ if modulePath == "" {
73+ modulePath = discoverModulePath(root)
74+ }
75+ st.cfg, st.modulePath = cfg, modulePath
76+ })
77+ if st.err != nil {
78+ return nil, st.err
4279 }
43- r := runner{pass: pass, cfg: cfg, modulePath: modulePath}
80+ r := runner{pass: pass, cfg: st.cfg, modulePath: st.modulePath, state: st}
4481 r.run()
45- return nil, nil
82+ return &Result{Severities: st.cfg.Rules}, nil
4683 },
4784 }
4885 if opts.Config == nil {
@@ -138,6 +175,8 @@ type runner struct {
138175 pass *analysis.Pass
139176 cfg *config.Config
140177 modulePath string
178+ state *state
179+ ignores ignoreIndex
141180 }
142181
143182 func (r runner) run() {
@@ -146,12 +185,14 @@ func (r runner) run() {
146185 if !ok || current.Role == config.RoleIgnore {
147186 return
148187 }
188+ r.ignores = ignoreIndex{}
149189 businessDiagnostics := 0
150190 for _, file := range r.pass.Files {
151191 filePath := r.filePath(file)
152192 if isGeneratedFile(file) {
153193 continue
154194 }
195+ r.collectIgnores(file)
155196 r.checkImports(file, filePath, current)
156197 r.checkTypeLeaks(file, filePath, current)
157198 r.checkMissingMocks(file, filePath, current)
@@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat
171212 switch {
172213 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
173214 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
174- case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:
175- r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
215+ case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
216+ r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
217+ case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
218+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
219+ case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
220+ r.report(imp.Pos(), "no-infra-imports-in-ports", filePath, "ports component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
221+ case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
222+ r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
176223 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
177224 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
178225 }
@@ -216,26 +263,61 @@ func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
216263 return
217264 }
218265 for _, field := range fields.List {
219- if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {
220- r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))
266+ t := r.pass.TypesInfo.TypeOf(field.Type)
267+ if r.isInfraType(t, nil) {
268+ r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
221269 }
222270 }
223271 }
224272
225-func (r runner) isInfraType(t types.Type) bool {
273+// isInfraType reports whether t, or any type it is composed of, belongs to an
274+// adapter/entrypoint component or a configured external framework package.
275+func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
226276 if t == nil {
227277 return false
228278 }
279+ if seen[t] {
280+ return false
281+ }
282+ if seen == nil {
283+ seen = map[types.Type]bool{}
284+ }
285+ seen[t] = true
229286 switch tt := t.(type) {
287+ case *types.Alias:
288+ return r.isInfraType(types.Unalias(tt), seen)
230289 case *types.Pointer:
231- return r.isInfraType(tt.Elem())
290+ return r.isInfraType(tt.Elem(), seen)
232291 case *types.Slice:
233- return r.isInfraType(tt.Elem())
292+ return r.isInfraType(tt.Elem(), seen)
234293 case *types.Array:
235- return r.isInfraType(tt.Elem())
294+ return r.isInfraType(tt.Elem(), seen)
295+ case *types.Chan:
296+ return r.isInfraType(tt.Elem(), seen)
236297 case *types.Map:
237- return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())
298+ return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
299+ case *types.Signature:
300+ return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
301+ case *types.Tuple:
302+ for v := range tt.Variables() {
303+ if r.isInfraType(v.Type(), seen) {
304+ return true
305+ }
306+ }
307+ return false
308+ case *types.Struct:
309+ for field := range tt.Fields() {
310+ if r.isInfraType(field.Type(), seen) {
311+ return true
312+ }
313+ }
314+ return false
238315 case *types.Named:
316+ for arg := range tt.TypeArgs().Types() {
317+ if r.isInfraType(arg, seen) {
318+ return true
319+ }
320+ }
239321 obj := tt.Obj()
240322 if obj == nil || obj.Pkg() == nil {
241323 return false
@@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool {
260342 return false
261343 }
262344
345+// report emits a diagnostic for rule unless it is disabled, allowed by config,
346+// excluded by path, or suppressed by an inline ignore directive.
263347 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
264- if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {
348+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
265349 return false
266350 }
267- r.pass.Reportf(pos, rule+": "+format, args...)
351+ r.pass.Report(analysis.Diagnostic{
352+ Pos: pos,
353+ Category: rule,
354+ Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
355+ })
268356 return true
269357 }
270358
@@ -1,13 +1,16 @@
1 package analyzer1 package analyzer
2 2
3 import (3 import (
4+ "fmt"
4 "go/ast"5 "go/ast"
5 "go/token"6 "go/token"
6 "go/types"7 "go/types"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
10+ "reflect"
9 "strconv"11 "strconv"
10 "strings"12 "strings"
13+ "sync"
11 14
12 "github.com/bnema/hexcheck/config"15 "github.com/bnema/hexcheck/config"
13 "github.com/bnema/hexcheck/internal/glob"16 "github.com/bnema/hexcheck/internal/glob"
@@ -23,26 +26,60 @@ type Options struct {
23 ModulePath string26 ModulePath string
24 }27 }
25 28
29+// Result is returned for each analyzed package. Drivers use it to map a
30+// diagnostic Category (the rule name) to its configured severity.
31+type Result struct {
32+ Severities map[string]config.Severity
33+}
34+
35+// Severity returns the configured severity of a diagnostic category.
36+func (r *Result) Severity(rule string) config.Severity {
37+ if r == nil {
38+ return config.SeverityOff
39+ }
40+ return r.Severities[rule]
41+}
42+
43+// state is shared by every package analyzed by one Analyzer instance, so the
44+// config and mock index are loaded once per run instead of once per package.
45+type state struct {
46+ once sync.Once
47+ cfg *config.Config
48+ modulePath string
49+ err error
50+ mockOnce sync.Once
51+ mocks map[string]bool
52+}
53+
26 func New(opts Options) *analysis.Analyzer {54 func New(opts Options) *analysis.Analyzer {
27 var configPathFlag string55 var configPathFlag string
28 var rootFlag string56 var rootFlag string
29 var modulePathFlag string57 var modulePathFlag string
58+ st := &state{}
30 59
31 a := &analysis.Analyzer{60 a := &analysis.Analyzer{
32- Name: Name,61+ Name: Name,
33- Doc: "checks hexagonal architecture boundaries",62+ Doc: "checks hexagonal architecture boundaries",
63+ ResultType: reflect.TypeFor[*Result](),
34 Run: func(pass *analysis.Pass) (any, error) {64 Run: func(pass *analysis.Pass) (any, error) {
35- cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)65+ st.once.Do(func() {
36- if err != nil {66+ cfg, root, err := resolveConfig(opts, configPathFlag, rootFlag)
37- return nil, err67+ if err != nil {
38- }68+ st.err = err
39- modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)69+ return
40- if modulePath == "" {70+ }
41- modulePath = discoverModulePath(root)71+ modulePath := firstNonEmpty(opts.ModulePath, modulePathFlag)
72+ if modulePath == "" {
73+ modulePath = discoverModulePath(root)
74+ }
75+ st.cfg, st.modulePath = cfg, modulePath
76+ })
77+ if st.err != nil {
78+ return nil, st.err
42 }79 }
43- r := runner{pass: pass, cfg: cfg, modulePath: modulePath}80+ r := runner{pass: pass, cfg: st.cfg, modulePath: st.modulePath, state: st}
44 r.run()81 r.run()
45- return nil, nil82+ return &Result{Severities: st.cfg.Rules}, nil
46 },83 },
47 }84 }
48 if opts.Config == nil {85 if opts.Config == nil {
@@ -138,6 +175,8 @@ type runner struct {
138 pass *analysis.Pass175 pass *analysis.Pass
139 cfg *config.Config176 cfg *config.Config
140 modulePath string177 modulePath string
178+ state *state
179+ ignores ignoreIndex
141 }180 }
142 181
143 func (r runner) run() {182 func (r runner) run() {
@@ -146,12 +185,14 @@ func (r runner) run() {
146 if !ok || current.Role == config.RoleIgnore {185 if !ok || current.Role == config.RoleIgnore {
147 return186 return
148 }187 }
188+ r.ignores = ignoreIndex{}
149 businessDiagnostics := 0189 businessDiagnostics := 0
150 for _, file := range r.pass.Files {190 for _, file := range r.pass.Files {
151 filePath := r.filePath(file)191 filePath := r.filePath(file)
152 if isGeneratedFile(file) {192 if isGeneratedFile(file) {
153 continue193 continue
154 }194 }
195+ r.collectIgnores(file)
155 r.checkImports(file, filePath, current)196 r.checkImports(file, filePath, current)
156 r.checkTypeLeaks(file, filePath, current)197 r.checkTypeLeaks(file, filePath, current)
157 r.checkMissingMocks(file, filePath, current)198 r.checkMissingMocks(file, filePath, current)
@@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat
171 switch {212 switch {
172 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):213 case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
173 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)214 r.report(imp.Pos(), "no-adapter-imports-in-core", filePath, "core component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
174- case current.Role == config.RoleUsecase && imported.Role == config.RoleAdapter:215+ case current.Role == config.RoleCore && imported.Role == config.RoleUsecase:
175- r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)216+ r.report(imp.Pos(), "no-usecase-imports-in-core", filePath, "core component %q imports usecase component %q (%s)", current.Name, imported.Name, importPath)
217+ case current.Role == config.RoleUsecase && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
218+ r.report(imp.Pos(), "no-infra-imports-in-usecase", filePath, "usecase component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
219+ case current.Role == config.RolePorts && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint):
220+ r.report(imp.Pos(), "no-infra-imports-in-ports", filePath, "ports component %q imports %s component %q (%s)", current.Name, imported.Role, imported.Name, importPath)
221+ case current.Role == config.RoleAdapter && imported.Role == config.RoleEntrypoint:
222+ r.report(imp.Pos(), "no-entrypoint-imports-in-adapter", filePath, "adapter component %q imports entrypoint component %q (%s)", current.Name, imported.Name, importPath)
176 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:223 case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name:
177 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)224 r.report(imp.Pos(), "no-adapter-to-adapter-imports", filePath, "adapter component %q imports adapter component %q (%s)", current.Name, imported.Name, importPath)
178 }225 }
@@ -216,26 +263,61 @@ func (r runner) checkFieldList(fields *ast.FieldList, filePath, rule string) {
216 return263 return
217 }264 }
218 for _, field := range fields.List {265 for _, field := range fields.List {
219- if r.isInfraType(r.pass.TypesInfo.TypeOf(field.Type)) {266+ t := r.pass.TypesInfo.TypeOf(field.Type)
220- r.report(field.Pos(), rule, filePath, "%s exposes infrastructure/framework type %s", rule, r.pass.TypesInfo.TypeOf(field.Type))267+ if r.isInfraType(t, nil) {
268+ r.report(field.Pos(), rule, filePath, "exposes infrastructure/framework type %s", t)
221 }269 }
222 }270 }
223 }271 }
224 272
225-func (r runner) isInfraType(t types.Type) bool {273+// isInfraType reports whether t, or any type it is composed of, belongs to an
274+// adapter/entrypoint component or a configured external framework package.
275+func (r runner) isInfraType(t types.Type, seen map[types.Type]bool) bool {
226 if t == nil {276 if t == nil {
227 return false277 return false
228 }278 }
279+ if seen[t] {
280+ return false
281+ }
282+ if seen == nil {
283+ seen = map[types.Type]bool{}
284+ }
285+ seen[t] = true
229 switch tt := t.(type) {286 switch tt := t.(type) {
287+ case *types.Alias:
288+ return r.isInfraType(types.Unalias(tt), seen)
230 case *types.Pointer:289 case *types.Pointer:
231- return r.isInfraType(tt.Elem())290+ return r.isInfraType(tt.Elem(), seen)
232 case *types.Slice:291 case *types.Slice:
233- return r.isInfraType(tt.Elem())292+ return r.isInfraType(tt.Elem(), seen)
234 case *types.Array:293 case *types.Array:
235- return r.isInfraType(tt.Elem())294+ return r.isInfraType(tt.Elem(), seen)
295+ case *types.Chan:
296+ return r.isInfraType(tt.Elem(), seen)
236 case *types.Map:297 case *types.Map:
237- return r.isInfraType(tt.Key()) || r.isInfraType(tt.Elem())298+ return r.isInfraType(tt.Key(), seen) || r.isInfraType(tt.Elem(), seen)
299+ case *types.Signature:
300+ return r.isInfraType(tt.Params(), seen) || r.isInfraType(tt.Results(), seen)
301+ case *types.Tuple:
302+ for v := range tt.Variables() {
303+ if r.isInfraType(v.Type(), seen) {
304+ return true
305+ }
306+ }
307+ return false
308+ case *types.Struct:
309+ for field := range tt.Fields() {
310+ if r.isInfraType(field.Type(), seen) {
311+ return true
312+ }
313+ }
314+ return false
238 case *types.Named:315 case *types.Named:
316+ for arg := range tt.TypeArgs().Types() {
317+ if r.isInfraType(arg, seen) {
318+ return true
319+ }
320+ }
239 obj := tt.Obj()321 obj := tt.Obj()
240 if obj == nil || obj.Pkg() == nil {322 if obj == nil || obj.Pkg() == nil {
241 return false323 return false
@@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool {
260 return false342 return false
261 }343 }
262 344
345+// report emits a diagnostic for rule unless it is disabled, allowed by config,
346+// excluded by path, or suppressed by an inline ignore directive.
263 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {347 func (r runner) report(pos token.Pos, rule, filePath, format string, args ...any) bool {
264- if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) {348+ if !r.cfg.RuleEnabled(rule) || r.cfg.IsAllowed(rule, filePath) || r.isExcluded(rule, filePath) || r.isIgnoredInline(pos, rule) {
265 return false349 return false
266 }350 }
267- r.pass.Reportf(pos, rule+": "+format, args...)351+ r.pass.Report(analysis.Diagnostic{
352+ Pos: pos,
353+ Category: rule,
354+ Message: fmt.Sprintf("[%s] %s: %s", r.cfg.Severity(rule), rule, fmt.Sprintf(format, args...)),
355+ })
268 return true356 return true
269 }357 }
270 358
M analyzer/analyzer_test.go +2 -0
@@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) {
1717 name: "boundary imports",
1818 patterns: []string{
1919 "example.com/project/internal/domain",
20+ "example.com/project/internal/policy",
2021 "example.com/project/internal/application/usecase",
22+ "example.com/project/internal/infrastructure/sql",
2123 },
2224 },
2325 {
@@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) {
17 name: "boundary imports",17 name: "boundary imports",
18 patterns: []string{18 patterns: []string{
19 "example.com/project/internal/domain",19 "example.com/project/internal/domain",
20+ "example.com/project/internal/policy",
20 "example.com/project/internal/application/usecase",21 "example.com/project/internal/application/usecase",
22+ "example.com/project/internal/infrastructure/sql",
21 },23 },
22 },24 },
23 {25 {
M analyzer/business_logic_golden_test.go +26 -26
@@ -8,45 +8,45 @@ import (
88 "golang.org/x/tools/go/analysis/analysistest"
99 )
1010
11+const businessLogicRule = "suspicious-business-logic-in-adapter"
12+
1113 func TestBusinessLogicGoldenCases(t *testing.T) {
1214 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")
1315
14- messagesByFile := map[string][]string{}
15- for _, diagnostics := range result[0].Diagnostics {
16- file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename)
17- messagesByFile[file] = append(messagesByFile[file], diagnostics.Message)
16+ // rulesByFile maps each file to the diagnostic categories reported in it.
17+ rulesByFile := map[string][]string{}
18+ for _, diagnostic := range result[0].Diagnostics {
19+ file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostic.Pos).Filename)
20+ rulesByFile[file] = append(rulesByFile[file], diagnostic.Category)
1821 }
1922
20- assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go")
21- assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go")
22- assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go")
23-}
24-
25-func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {
26- t.Helper()
27- for file, messages := range messagesByFile {
28- if !strings.HasSuffix(file, fileSuffix) {
29- continue
30- }
31- for _, message := range messages {
32- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
33- return
23+ tests := []struct {
24+ file string
25+ want bool
26+ }{
27+ {file: "typed_policy.go", want: true},
28+ {file: "mixed_detection.go", want: true},
29+ {file: "probing.go", want: false},
30+ }
31+ for _, tt := range tests {
32+ t.Run(tt.file, func(t *testing.T) {
33+ if got := hasRuleInFile(rulesByFile, tt.file, businessLogicRule); got != tt.want {
34+ t.Fatalf("business-logic diagnostic in %s = %v, want %v", tt.file, got, tt.want)
3435 }
35- }
36+ })
3637 }
37- t.Fatalf("expected business-logic diagnostic for %s", fileSuffix)
3838 }
3939
40-func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {
41- t.Helper()
42- for file, messages := range messagesByFile {
40+func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool {
41+ for file, rules := range rulesByFile {
4342 if !strings.HasSuffix(file, fileSuffix) {
4443 continue
4544 }
46- for _, message := range messages {
47- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
48- t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message)
45+ for _, got := range rules {
46+ if got == rule {
47+ return true
4948 }
5049 }
5150 }
51+ return false
5252 }
@@ -8,45 +8,45 @@ import (
8 "golang.org/x/tools/go/analysis/analysistest"8 "golang.org/x/tools/go/analysis/analysistest"
9 )9 )
10 10
11+const businessLogicRule = "suspicious-business-logic-in-adapter"
12+
11 func TestBusinessLogicGoldenCases(t *testing.T) {13 func TestBusinessLogicGoldenCases(t *testing.T) {
12 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")14 result := analysistest.Run(t, analysistest.TestData(), New(Options{Config: testConfig(), ModulePath: "example.com/project"}), "example.com/project/internal/infrastructure/http")
13 15
14- messagesByFile := map[string][]string{}16+ // rulesByFile maps each file to the diagnostic categories reported in it.
15- for _, diagnostics := range result[0].Diagnostics {17+ rulesByFile := map[string][]string{}
16- file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostics.Pos).Filename)18+ for _, diagnostic := range result[0].Diagnostics {
17- messagesByFile[file] = append(messagesByFile[file], diagnostics.Message)19+ file := filepath.ToSlash(result[0].Pass.Fset.Position(diagnostic.Pos).Filename)
20+ rulesByFile[file] = append(rulesByFile[file], diagnostic.Category)
18 }21 }
19 22
20- assertHasBusinessLogicDiagnostic(t, messagesByFile, "typed_policy.go")23+ tests := []struct {
21- assertHasBusinessLogicDiagnostic(t, messagesByFile, "mixed_detection.go")24+ file string
22- assertNoBusinessLogicDiagnostic(t, messagesByFile, "probing.go")25+ want bool
23-}26+ }{
24-27+ {file: "typed_policy.go", want: true},
25-func assertHasBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {28+ {file: "mixed_detection.go", want: true},
26- t.Helper()29+ {file: "probing.go", want: false},
27- for file, messages := range messagesByFile {30+ }
28- if !strings.HasSuffix(file, fileSuffix) {31+ for _, tt := range tests {
29- continue32+ t.Run(tt.file, func(t *testing.T) {
30- }33+ if got := hasRuleInFile(rulesByFile, tt.file, businessLogicRule); got != tt.want {
31- for _, message := range messages {34+ t.Fatalf("business-logic diagnostic in %s = %v, want %v", tt.file, got, tt.want)
32- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {
33- return
34 }35 }
35- }36+ })
36 }37 }
37- t.Fatalf("expected business-logic diagnostic for %s", fileSuffix)
38 }38 }
39 39
40-func assertNoBusinessLogicDiagnostic(t *testing.T, messagesByFile map[string][]string, fileSuffix string) {40+func hasRuleInFile(rulesByFile map[string][]string, fileSuffix, rule string) bool {
41- t.Helper()41+ for file, rules := range rulesByFile {
42- for file, messages := range messagesByFile {
43 if !strings.HasSuffix(file, fileSuffix) {42 if !strings.HasSuffix(file, fileSuffix) {
44 continue43 continue
45 }44 }
46- for _, message := range messages {45+ for _, got := range rules {
47- if strings.HasPrefix(message, "suspicious-business-logic-in-adapter:") {46+ if got == rule {
48- t.Fatalf("unexpected business-logic diagnostic for %s: %s", fileSuffix, message)47+ return true
49 }48 }
50 }49 }
51 }50 }
51+ return false
52 }52 }
A analyzer/ignore.go +66 -0
@@ -0,0 +1,66 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+// DirectiveCategory is the diagnostic category used for malformed
13+// //hexcheck:ignore directives.
14+const DirectiveCategory = "invalid-ignore-directive"
15+
16+const ignoreDirective = "//hexcheck:ignore"
17+
18+type ignoreKey struct {
19+ file string
20+ line int
21+ rule string
22+}
23+
24+// ignoreIndex records which rules are suppressed on which lines.
25+type ignoreIndex map[ignoreKey]bool
26+
27+// collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives.
28+// A directive suppresses matching diagnostics on its own line and on the
29+// following line. A reason is required, like config allow entries.
30+func (r runner) collectIgnores(file *ast.File) {
31+ known := config.DefaultRuleSeverities()
32+ for _, group := range file.Comments {
33+ for _, comment := range group.List {
34+ rest, ok := strings.CutPrefix(comment.Text, ignoreDirective)
35+ if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
36+ continue
37+ }
38+ fields := strings.Fields(rest)
39+ if len(fields) < 2 {
40+ r.reportDirective(comment.Pos(), "hexcheck:ignore needs a rule list and a reason")
41+ continue
42+ }
43+ pos := r.pass.Fset.Position(comment.Pos())
44+ for rule := range strings.SplitSeq(fields[0], ",") {
45+ if _, ok := known[rule]; !ok {
46+ r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule)
47+ continue
48+ }
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
51+ }
52+ }
53+ }
54+}
55+
56+func (r runner) isIgnoredInline(pos token.Pos, rule string) bool {
57+ if r.ignores == nil {
58+ return false
59+ }
60+ p := r.pass.Fset.Position(pos)
61+ return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}]
62+}
63+
64+func (r runner) reportDirective(pos token.Pos, msg string) {
65+ r.pass.Report(analysis.Diagnostic{Pos: pos, Category: DirectiveCategory, Message: "[error] " + DirectiveCategory + ": " + msg})
66+}
@@ -0,0 +1,66 @@
1+package analyzer
2+
3+import (
4+ "go/ast"
5+ "go/token"
6+ "strings"
7+
8+ "github.com/bnema/hexcheck/config"
9+ "golang.org/x/tools/go/analysis"
10+)
11+
12+// DirectiveCategory is the diagnostic category used for malformed
13+// //hexcheck:ignore directives.
14+const DirectiveCategory = "invalid-ignore-directive"
15+
16+const ignoreDirective = "//hexcheck:ignore"
17+
18+type ignoreKey struct {
19+ file string
20+ line int
21+ rule string
22+}
23+
24+// ignoreIndex records which rules are suppressed on which lines.
25+type ignoreIndex map[ignoreKey]bool
26+
27+// collectIgnores indexes `//hexcheck:ignore rule[,rule] reason` directives.
28+// A directive suppresses matching diagnostics on its own line and on the
29+// following line. A reason is required, like config allow entries.
30+func (r runner) collectIgnores(file *ast.File) {
31+ known := config.DefaultRuleSeverities()
32+ for _, group := range file.Comments {
33+ for _, comment := range group.List {
34+ rest, ok := strings.CutPrefix(comment.Text, ignoreDirective)
35+ if !ok || (rest != "" && rest[0] != ' ' && rest[0] != '\t') {
36+ continue
37+ }
38+ fields := strings.Fields(rest)
39+ if len(fields) < 2 {
40+ r.reportDirective(comment.Pos(), "hexcheck:ignore needs a rule list and a reason")
41+ continue
42+ }
43+ pos := r.pass.Fset.Position(comment.Pos())
44+ for rule := range strings.SplitSeq(fields[0], ",") {
45+ if _, ok := known[rule]; !ok {
46+ r.reportDirective(comment.Pos(), "hexcheck:ignore names unknown rule "+rule)
47+ continue
48+ }
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
51+ }
52+ }
53+ }
54+}
55+
56+func (r runner) isIgnoredInline(pos token.Pos, rule string) bool {
57+ if r.ignores == nil {
58+ return false
59+ }
60+ p := r.pass.Fset.Position(pos)
61+ return r.ignores[ignoreKey{file: p.Filename, line: p.Line, rule: rule}]
62+}
63+
64+func (r runner) reportDirective(pos token.Pos, msg string) {
65+ r.pass.Report(analysis.Diagnostic{Pos: pos, Category: DirectiveCategory, Message: "[error] " + DirectiveCategory + ": " + msg})
66+}
M analyzer/mocks.go +11 -21
@@ -7,10 +7,8 @@ import (
77 "go/types"
88 "os"
99 "path/filepath"
10- "sort"
1110 "strconv"
1211 "strings"
13- "sync"
1412
1513 "github.com/bnema/hexcheck/config"
1614 "github.com/bnema/hexcheck/internal/glob"
@@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
110108 return out
111109 }
112110
113-var mockIndexCache sync.Map
114-
115111 func (r runner) generatedMockExists(interfaceName string) bool {
116112 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
117113 return false
118114 }
119- index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
115+ index := r.mockIndex()
120116 for _, name := range r.expectedMockNames(interfaceName) {
121117 if index[name] {
122118 return true
@@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool {
125121 return false
126122 }
127123
128-func cachedMockIndex(root string, patterns []string) map[string]bool {
129- key := mockIndexKey(root, patterns)
130- if cached, ok := mockIndexCache.Load(key); ok {
131- return cached.(map[string]bool)
124+// mockIndex returns the generated mock type names, built once per analyzer
125+// run. The index lives in the analyzer state, not in a process-wide cache, so
126+// each new run sees freshly generated mocks.
127+func (r runner) mockIndex() map[string]bool {
128+ if r.state == nil {
129+ return buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
132130 }
133- index := buildMockIndex(root, patterns)
134- actual, _ := mockIndexCache.LoadOrStore(key, index)
135- return actual.(map[string]bool)
136-}
137-
138-func mockIndexKey(root string, patterns []string) string {
139- patterns = append([]string(nil), patterns...)
140- sort.Strings(patterns)
141- return root + "\x00" + strings.Join(patterns, "\x00")
131+ r.state.mockOnce.Do(func() {
132+ r.state.mocks = buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
133+ })
134+ return r.state.mocks
142135 }
143136
144137 func buildMockIndex(root string, patterns []string) map[string]bool {
@@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool {
210203 }
211204
212205 func hasWordPrefix(name, prefix string) bool {
213- if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214- return true
215- }
216206 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217207 return false
218208 }
@@ -7,10 +7,8 @@ import (
7 "go/types"7 "go/types"
8 "os"8 "os"
9 "path/filepath"9 "path/filepath"
10- "sort"
11 "strconv"10 "strconv"
12 "strings"11 "strings"
13- "sync"
14 12
15 "github.com/bnema/hexcheck/config"13 "github.com/bnema/hexcheck/config"
16 "github.com/bnema/hexcheck/internal/glob"14 "github.com/bnema/hexcheck/internal/glob"
@@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface {
110 return out108 return out
111 }109 }
112 110
113-var mockIndexCache sync.Map
114-
115 func (r runner) generatedMockExists(interfaceName string) bool {111 func (r runner) generatedMockExists(interfaceName string) bool {
116 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {112 if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 {
117 return false113 return false
118 }114 }
119- index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)115+ index := r.mockIndex()
120 for _, name := range r.expectedMockNames(interfaceName) {116 for _, name := range r.expectedMockNames(interfaceName) {
121 if index[name] {117 if index[name] {
122 return true118 return true
@@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool {
125 return false121 return false
126 }122 }
127 123
128-func cachedMockIndex(root string, patterns []string) map[string]bool {124+// mockIndex returns the generated mock type names, built once per analyzer
129- key := mockIndexKey(root, patterns)125+// run. The index lives in the analyzer state, not in a process-wide cache, so
130- if cached, ok := mockIndexCache.Load(key); ok {126+// each new run sees freshly generated mocks.
131- return cached.(map[string]bool)127+func (r runner) mockIndex() map[string]bool {
128+ if r.state == nil {
129+ return buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
132 }130 }
133- index := buildMockIndex(root, patterns)131+ r.state.mockOnce.Do(func() {
134- actual, _ := mockIndexCache.LoadOrStore(key, index)132+ r.state.mocks = buildMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths)
135- return actual.(map[string]bool)133+ })
136-}134+ return r.state.mocks
137-
138-func mockIndexKey(root string, patterns []string) string {
139- patterns = append([]string(nil), patterns...)
140- sort.Strings(patterns)
141- return root + "\x00" + strings.Join(patterns, "\x00")
142 }135 }
143 136
144 func buildMockIndex(root string, patterns []string) map[string]bool {137 func buildMockIndex(root string, patterns []string) map[string]bool {
@@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool {
210 }203 }
211 204
212 func hasWordPrefix(name, prefix string) bool {205 func hasWordPrefix(name, prefix string) bool {
213- if len(name) >= len(prefix) && strings.EqualFold(name[:len(prefix)], prefix) && len(name) == len(prefix) {
214- return true
215- }
216 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {206 if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) {
217 return false207 return false
218 }208 }
A analyzer/testdata/src/example.com/project/cmd/tools/tools.go +3 -0
@@ -0,0 +1,3 @@
1+package tools
2+
3+type Flags struct{ Verbose bool }
@@ -0,0 +1,3 @@
1+package tools
2+
3+type Flags struct{ Verbose bool }
A analyzer/testdata/src/example.com/project/internal/application/port/leaks.go +37 -0
@@ -0,0 +1,37 @@
1+package port
2+
3+import (
4+ "example.com/framework"
5+ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
6+)
7+
8+type Box[T any] struct{ Value T }
9+
10+type Streams interface {
11+ Rows() <-chan sql.Row // want "no-infra-types-in-ports"
12+ Each(fn func(*sql.Row) error) error // want "no-infra-types-in-ports"
13+ Boxed() Box[*framework.Context] // want "no-infra-types-in-ports"
14+ Inline() struct{ Ctx *framework.Context } // want "no-infra-types-in-ports"
15+ Plain(id string) error
16+}
17+
18+type Ignored interface {
19+ //hexcheck:ignore no-infra-types-in-ports transport row is part of the published contract
20+ Raw() sql.Row
21+}
22+
23+type SameLine interface {
24+ Raw() sql.Row //hexcheck:ignore no-infra-types-in-ports published contract
25+}
26+
27+type BadDirective interface {
28+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore needs a rule list and a reason" */ //hexcheck:ignore no-infra-types-in-ports
29+}
30+
31+type UnknownDirective interface {
32+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore names unknown rule not-a-rule" */ //hexcheck:ignore not-a-rule some reason
33+}
34+
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
37+}
@@ -0,0 +1,37 @@
1+package port
2+
3+import (
4+ "example.com/framework"
5+ "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
6+)
7+
8+type Box[T any] struct{ Value T }
9+
10+type Streams interface {
11+ Rows() <-chan sql.Row // want "no-infra-types-in-ports"
12+ Each(fn func(*sql.Row) error) error // want "no-infra-types-in-ports"
13+ Boxed() Box[*framework.Context] // want "no-infra-types-in-ports"
14+ Inline() struct{ Ctx *framework.Context } // want "no-infra-types-in-ports"
15+ Plain(id string) error
16+}
17+
18+type Ignored interface {
19+ //hexcheck:ignore no-infra-types-in-ports transport row is part of the published contract
20+ Raw() sql.Row
21+}
22+
23+type SameLine interface {
24+ Raw() sql.Row //hexcheck:ignore no-infra-types-in-ports published contract
25+}
26+
27+type BadDirective interface {
28+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore needs a rule list and a reason" */ //hexcheck:ignore no-infra-types-in-ports
29+}
30+
31+type UnknownDirective interface {
32+ Raw() sql.Row /* want "no-infra-types-in-ports" "invalid-ignore-directive: hexcheck:ignore names unknown rule not-a-rule" */ //hexcheck:ignore not-a-rule some reason
33+}
34+
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
37+}
A analyzer/testdata/src/example.com/project/internal/application/port/mocks/leak_mocks.go +8 -0
@@ -0,0 +1,8 @@
1+package mocks
2+
3+type MockStreams struct{}
4+type MockIgnored struct{}
5+type MockSameLine struct{}
6+type MockBadDirective struct{}
7+type MockUnknownDirective struct{}
8+type MockOtherRuleDirective struct{}
@@ -0,0 +1,8 @@
1+package mocks
2+
3+type MockStreams struct{}
4+type MockIgnored struct{}
5+type MockSameLine struct{}
6+type MockBadDirective struct{}
7+type MockUnknownDirective struct{}
8+type MockOtherRuleDirective struct{}
M analyzer/testdata/src/example.com/project/internal/application/port/port.go +1 -1
@@ -1,6 +1,6 @@
11 package port
22
3-import "example.com/project/internal/infrastructure/sql"
3+import "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
44
55 type UserRepository interface {
66 Find() (*sql.Row, error) // want "no-infra-types-in-ports"
@@ -1,6 +1,6 @@
1 package port1 package port
2 2
3-import "example.com/project/internal/infrastructure/sql"3+import "example.com/project/internal/infrastructure/sql" // want "no-infra-imports-in-ports"
4 4
5 type UserRepository interface {5 type UserRepository interface {
6 Find() (*sql.Row, error) // want "no-infra-types-in-ports"6 Find() (*sql.Row, error) // want "no-infra-types-in-ports"
A analyzer/testdata/src/example.com/project/internal/application/usecase/entrypoint.go +3 -0
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/cmd/tools" // want "no-infra-imports-in-usecase: usecase component \"usecase\" imports entrypoint"
@@ -0,0 +1,3 @@
1+package usecase
2+
3+import _ "example.com/project/cmd/tools" // want "no-infra-imports-in-usecase: usecase component \"usecase\" imports entrypoint"
A analyzer/testdata/src/example.com/project/internal/infrastructure/sql/entrypoint.go +3 -0
@@ -0,0 +1,3 @@
1+package sql
2+
3+import _ "example.com/project/cmd/tools" // want "no-entrypoint-imports-in-adapter"
@@ -0,0 +1,3 @@
1+package sql
2+
3+import _ "example.com/project/cmd/tools" // want "no-entrypoint-imports-in-adapter"
A analyzer/testdata/src/example.com/project/internal/policy/usecase.go +3 -0
@@ -0,0 +1,3 @@
1+package policy
2+
3+import _ "example.com/project/internal/application/usecase" // want "no-usecase-imports-in-core"
@@ -0,0 +1,3 @@
1+package policy
2+
3+import _ "example.com/project/internal/application/usecase" // want "no-usecase-imports-in-core"
M config/config.go +21 -2
@@ -1,8 +1,10 @@
11 package config
22
33 import (
4+ "bytes"
45 "errors"
56 "fmt"
7+ "io"
68 "os"
79 "path/filepath"
810 "sort"
@@ -90,8 +92,10 @@ func Load(path string) (*Config, error) {
9092 return nil, err
9193 }
9294 var cfg Config
93- if err := yaml.Unmarshal(data, &cfg); err != nil {
94- return nil, err
95+ decoder := yaml.NewDecoder(bytes.NewReader(data))
96+ decoder.KnownFields(true)
97+ if err := decoder.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
98+ return nil, fmt.Errorf("%s: %w", path, err)
9599 }
96100 if hasExplicitVersion(data) && cfg.Version == 0 {
97101 return nil, errors.New("version: unsupported value 0")
@@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool {
185189 func DefaultRuleSeverities() map[string]Severity {
186190 return map[string]Severity{
187191 "no-adapter-imports-in-core": SeverityError,
192+ "no-usecase-imports-in-core": SeverityError,
188193 "no-infra-imports-in-usecase": SeverityError,
194+ "no-infra-imports-in-ports": SeverityError,
195+ "no-entrypoint-imports-in-adapter": SeverityError,
189196 "no-framework-types-in-core": SeverityError,
190197 "no-infra-types-in-ports": SeverityError,
191198 "no-adapter-to-adapter-imports": SeverityWarn,
@@ -238,17 +245,29 @@ func (c *Config) Validate() error {
238245 default:
239246 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
240247 }
248+ known := DefaultRuleSeverities()
241249 for rule, severity := range c.Rules {
250+ if _, ok := known[rule]; !ok {
251+ return fmt.Errorf("rules.%s: unknown rule", rule)
252+ }
242253 switch severity {
243254 case SeverityOff, SeverityWarn, SeverityError:
244255 default:
245256 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
246257 }
247258 }
259+ for rule := range c.RuleSettings {
260+ if _, ok := known[rule]; !ok {
261+ return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
262+ }
263+ }
248264 for i, allow := range c.Allow {
249265 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
250266 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
251267 }
268+ if _, ok := known[allow.Rule]; !ok {
269+ return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
270+ }
252271 }
253272 return nil
254273 }
@@ -1,8 +1,10 @@
1 package config1 package config
2 2
3 import (3 import (
4+ "bytes"
4 "errors"5 "errors"
5 "fmt"6 "fmt"
7+ "io"
6 "os"8 "os"
7 "path/filepath"9 "path/filepath"
8 "sort"10 "sort"
@@ -90,8 +92,10 @@ func Load(path string) (*Config, error) {
90 return nil, err92 return nil, err
91 }93 }
92 var cfg Config94 var cfg Config
93- if err := yaml.Unmarshal(data, &cfg); err != nil {95+ decoder := yaml.NewDecoder(bytes.NewReader(data))
94- return nil, err96+ decoder.KnownFields(true)
97+ if err := decoder.Decode(&cfg); err != nil && !errors.Is(err, io.EOF) {
98+ return nil, fmt.Errorf("%s: %w", path, err)
95 }99 }
96 if hasExplicitVersion(data) && cfg.Version == 0 {100 if hasExplicitVersion(data) && cfg.Version == 0 {
97 return nil, errors.New("version: unsupported value 0")101 return nil, errors.New("version: unsupported value 0")
@@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool {
185 func DefaultRuleSeverities() map[string]Severity {189 func DefaultRuleSeverities() map[string]Severity {
186 return map[string]Severity{190 return map[string]Severity{
187 "no-adapter-imports-in-core": SeverityError,191 "no-adapter-imports-in-core": SeverityError,
192+ "no-usecase-imports-in-core": SeverityError,
188 "no-infra-imports-in-usecase": SeverityError,193 "no-infra-imports-in-usecase": SeverityError,
194+ "no-infra-imports-in-ports": SeverityError,
195+ "no-entrypoint-imports-in-adapter": SeverityError,
189 "no-framework-types-in-core": SeverityError,196 "no-framework-types-in-core": SeverityError,
190 "no-infra-types-in-ports": SeverityError,197 "no-infra-types-in-ports": SeverityError,
191 "no-adapter-to-adapter-imports": SeverityWarn,198 "no-adapter-to-adapter-imports": SeverityWarn,
@@ -238,17 +245,29 @@ func (c *Config) Validate() error {
238 default:245 default:
239 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")246 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
240 }247 }
248+ known := DefaultRuleSeverities()
241 for rule, severity := range c.Rules {249 for rule, severity := range c.Rules {
250+ if _, ok := known[rule]; !ok {
251+ return fmt.Errorf("rules.%s: unknown rule", rule)
252+ }
242 switch severity {253 switch severity {
243 case SeverityOff, SeverityWarn, SeverityError:254 case SeverityOff, SeverityWarn, SeverityError:
244 default:255 default:
245 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)256 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
246 }257 }
247 }258 }
259+ for rule := range c.RuleSettings {
260+ if _, ok := known[rule]; !ok {
261+ return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
262+ }
263+ }
248 for i, allow := range c.Allow {264 for i, allow := range c.Allow {
249 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {265 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
250 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)266 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
251 }267 }
268+ if _, ok := known[allow.Rule]; !ok {
269+ return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
270+ }
252 }271 }
253 return nil272 return nil
254 }273 }
M config/config_test.go +35 -0
@@ -46,6 +46,41 @@ allow:
4646 }
4747 },
4848 },
49+ {
50+ name: "unknown top-level key",
51+ yaml: "version: 1\ncomponent: {}\n",
52+ wantErr: true,
53+ },
54+ {
55+ name: "unknown nested key",
56+ yaml: "version: 1\nheuristics:\n businessLogicTreshold: 3\n",
57+ wantErr: true,
58+ },
59+ {
60+ name: "unknown rule name",
61+ yaml: "version: 1\nrules:\n no-adaptr-imports-in-core: error\n",
62+ wantErr: true,
63+ },
64+ {
65+ name: "unknown rule in ruleSettings",
66+ yaml: "version: 1\nruleSettings:\n typo-rule:\n excludePaths: [cmd/**]\n",
67+ wantErr: true,
68+ },
69+ {
70+ name: "unknown rule in allow",
71+ yaml: "version: 1\nallow:\n - rule: typo-rule\n path: cmd/**\n reason: x\n",
72+ wantErr: true,
73+ },
74+ {
75+ name: "empty file uses defaults",
76+ yaml: "",
77+ check: func(t *testing.T, cfg *Config) {
78+ t.Helper()
79+ if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80+ t.Fatalf("default severity not applied")
81+ }
82+ },
83+ },
4984 {
5085 name: "invalid role",
5186 yaml: `version: 1
@@ -46,6 +46,41 @@ allow:
46 }46 }
47 },47 },
48 },48 },
49+ {
50+ name: "unknown top-level key",
51+ yaml: "version: 1\ncomponent: {}\n",
52+ wantErr: true,
53+ },
54+ {
55+ name: "unknown nested key",
56+ yaml: "version: 1\nheuristics:\n businessLogicTreshold: 3\n",
57+ wantErr: true,
58+ },
59+ {
60+ name: "unknown rule name",
61+ yaml: "version: 1\nrules:\n no-adaptr-imports-in-core: error\n",
62+ wantErr: true,
63+ },
64+ {
65+ name: "unknown rule in ruleSettings",
66+ yaml: "version: 1\nruleSettings:\n typo-rule:\n excludePaths: [cmd/**]\n",
67+ wantErr: true,
68+ },
69+ {
70+ name: "unknown rule in allow",
71+ yaml: "version: 1\nallow:\n - rule: typo-rule\n path: cmd/**\n reason: x\n",
72+ wantErr: true,
73+ },
74+ {
75+ name: "empty file uses defaults",
76+ yaml: "",
77+ check: func(t *testing.T, cfg *Config) {
78+ t.Helper()
79+ if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80+ t.Fatalf("default severity not applied")
81+ }
82+ },
83+ },
49 {84 {
50 name: "invalid role",85 name: "invalid role",
51 yaml: `version: 186 yaml: `version: 1

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