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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +329 -72
M analyzer/analyzer.go +110 -22
| @@ -1,13 +1,16 @@ | |||
| 1 | 1 | package analyzer | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "go/ast" | |
| 5 | 6 | "go/token" | |
| 6 | 7 | "go/types" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 10 | + | "reflect" | |
| 9 | 11 | "strconv" | |
| 10 | 12 | "strings" | |
| 13 | + | "sync" | |
| 11 | 14 | ||
| 12 | 15 | "github.com/bnema/hexcheck/config" | |
| 13 | 16 | "github.com/bnema/hexcheck/internal/glob" | |
| @@ -23,26 +26,60 @@ type Options struct { | |||
| 23 | 26 | 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 | 54 | func New(opts Options) *analysis.Analyzer { | |
| 27 | 55 | var configPathFlag string | |
| 28 | 56 | var rootFlag string | |
| 29 | 57 | var modulePathFlag string | |
| 58 | + | st := &state{} | |
| 30 | 59 | ||
| 31 | 60 | 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](), | |
| 34 | 64 | 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 | |
| 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 | 81 | r.run() | |
| 45 | - | return nil, nil | |
| 82 | + | return &Result{Severities: st.cfg.Rules}, nil | |
| 46 | 83 | }, | |
| 47 | 84 | } | |
| 48 | 85 | if opts.Config == nil { | |
| @@ -138,6 +175,8 @@ type runner struct { | |||
| 138 | 175 | pass *analysis.Pass | |
| 139 | 176 | cfg *config.Config | |
| 140 | 177 | modulePath string | |
| 178 | + | state *state | |
| 179 | + | ignores ignoreIndex | |
| 141 | 180 | } | |
| 142 | 181 | ||
| 143 | 182 | func (r runner) run() { | |
| @@ -146,12 +185,14 @@ func (r runner) run() { | |||
| 146 | 185 | if !ok || current.Role == config.RoleIgnore { | |
| 147 | 186 | return | |
| 148 | 187 | } | |
| 188 | + | r.ignores = ignoreIndex{} | |
| 149 | 189 | businessDiagnostics := 0 | |
| 150 | 190 | for _, file := range r.pass.Files { | |
| 151 | 191 | filePath := r.filePath(file) | |
| 152 | 192 | if isGeneratedFile(file) { | |
| 153 | 193 | continue | |
| 154 | 194 | } | |
| 195 | + | r.collectIgnores(file) | |
| 155 | 196 | r.checkImports(file, filePath, current) | |
| 156 | 197 | r.checkTypeLeaks(file, filePath, current) | |
| 157 | 198 | r.checkMissingMocks(file, filePath, current) | |
| @@ -171,8 +212,14 @@ func (r runner) checkImports(file *ast.File, filePath string, current config.Mat | |||
| 171 | 212 | switch { | |
| 172 | 213 | case current.Role == config.RoleCore && (imported.Role == config.RoleAdapter || imported.Role == config.RoleEntrypoint): | |
| 173 | 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: | |
| 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) | |
| 176 | 223 | case current.Role == config.RoleAdapter && imported.Role == config.RoleAdapter && current.Name != imported.Name: | |
| 177 | 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 | 263 | return | |
| 217 | 264 | } | |
| 218 | 265 | 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) | |
| 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 | 276 | if t == nil { | |
| 227 | 277 | 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 | 286 | switch tt := t.(type) { | |
| 287 | + | case *types.Alias: | |
| 288 | + | return r.isInfraType(types.Unalias(tt), seen) | |
| 230 | 289 | case *types.Pointer: | |
| 231 | - | return r.isInfraType(tt.Elem()) | |
| 290 | + | return r.isInfraType(tt.Elem(), seen) | |
| 232 | 291 | case *types.Slice: | |
| 233 | - | return r.isInfraType(tt.Elem()) | |
| 292 | + | return r.isInfraType(tt.Elem(), seen) | |
| 234 | 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 | 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 | 315 | case *types.Named: | |
| 316 | + | for arg := range tt.TypeArgs().Types() { | |
| 317 | + | if r.isInfraType(arg, seen) { | |
| 318 | + | return true | |
| 319 | + | } | |
| 320 | + | } | |
| 239 | 321 | obj := tt.Obj() | |
| 240 | 322 | if obj == nil || obj.Pkg() == nil { | |
| 241 | 323 | return false | |
| @@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool { | |||
| 260 | 342 | 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 | 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 | 349 | 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 | 356 | return true | |
| 269 | 357 | } | |
| 270 | 358 | ||
| @@ -1,13 +1,16 @@ | |||||
| 1 | package analyzer | 1 | 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 string | 26 | 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 string | 55 | var configPathFlag string | ||
| 28 | var rootFlag string | 56 | var rootFlag string | ||
| 29 | var modulePathFlag string | 57 | 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, err | 67 | + | 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, nil | 82 | + | 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.Pass | 175 | pass *analysis.Pass | ||
| 139 | cfg *config.Config | 176 | cfg *config.Config | ||
| 140 | modulePath string | 177 | 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 | return | 186 | return | ||
| 148 | } | 187 | } | ||
| 188 | + | r.ignores = ignoreIndex{} | |||
| 149 | businessDiagnostics := 0 | 189 | 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 | continue | 193 | 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 | return | 263 | 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 false | 277 | 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 false | 323 | return false | ||
| @@ -260,11 +342,17 @@ func (r runner) matchesExternal(pkg string) bool { | |||||
| 260 | return false | 342 | 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 false | 349 | 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 true | 356 | return true | ||
| 269 | } | 357 | } | ||
| 270 | 358 | ||||
M analyzer/analyzer_test.go +2 -0
| @@ -17,7 +17,9 @@ func TestAnalyzer(t *testing.T) { | |||
| 17 | 17 | name: "boundary imports", | |
| 18 | 18 | patterns: []string{ | |
| 19 | 19 | "example.com/project/internal/domain", | |
| 20 | + | "example.com/project/internal/policy", | |
| 20 | 21 | "example.com/project/internal/application/usecase", | |
| 22 | + | "example.com/project/internal/infrastructure/sql", | |
| 21 | 23 | }, | |
| 22 | 24 | }, | |
| 23 | 25 | { | |
| @@ -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 ( | |||
| 8 | 8 | "golang.org/x/tools/go/analysis/analysistest" | |
| 9 | 9 | ) | |
| 10 | 10 | ||
| 11 | + | const businessLogicRule = "suspicious-business-logic-in-adapter" | |
| 12 | + | ||
| 11 | 13 | func TestBusinessLogicGoldenCases(t *testing.T) { | |
| 12 | 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{} | |
| 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) | |
| 18 | 21 | } | |
| 19 | 22 | ||
| 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) | |
| 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) { | |
| 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 { | |
| 43 | 42 | if !strings.HasSuffix(file, fileSuffix) { | |
| 44 | 43 | continue | |
| 45 | 44 | } | |
| 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 | |
| 49 | 48 | } | |
| 50 | 49 | } | |
| 51 | 50 | } | |
| 51 | + | return false | |
| 52 | 52 | } | |
| @@ -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 | - | continue | 32 | + | 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 | continue | 43 | 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 ( | |||
| 7 | 7 | "go/types" | |
| 8 | 8 | "os" | |
| 9 | 9 | "path/filepath" | |
| 10 | - | "sort" | |
| 11 | 10 | "strconv" | |
| 12 | 11 | "strings" | |
| 13 | - | "sync" | |
| 14 | 12 | ||
| 15 | 13 | "github.com/bnema/hexcheck/config" | |
| 16 | 14 | "github.com/bnema/hexcheck/internal/glob" | |
| @@ -110,13 +108,11 @@ func (r runner) importedPortInterfaces(file *ast.File) []portInterface { | |||
| 110 | 108 | return out | |
| 111 | 109 | } | |
| 112 | 110 | ||
| 113 | - | var mockIndexCache sync.Map | |
| 114 | - | ||
| 115 | 111 | func (r runner) generatedMockExists(interfaceName string) bool { | |
| 116 | 112 | if r.cfg.Root == "" || len(r.cfg.Mocking.GeneratedMockPaths) == 0 { | |
| 117 | 113 | return false | |
| 118 | 114 | } | |
| 119 | - | index := cachedMockIndex(r.cfg.Root, r.cfg.Mocking.GeneratedMockPaths) | |
| 115 | + | index := r.mockIndex() | |
| 120 | 116 | for _, name := range r.expectedMockNames(interfaceName) { | |
| 121 | 117 | if index[name] { | |
| 122 | 118 | return true | |
| @@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool { | |||
| 125 | 121 | return false | |
| 126 | 122 | } | |
| 127 | 123 | ||
| 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) | |
| 132 | 130 | } | |
| 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 | |
| 142 | 135 | } | |
| 143 | 136 | ||
| 144 | 137 | func buildMockIndex(root string, patterns []string) map[string]bool { | |
| @@ -210,9 +203,6 @@ func isTestDoubleName(name string) bool { | |||
| 210 | 203 | } | |
| 211 | 204 | ||
| 212 | 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 | 206 | if len(name) < len(prefix) || !strings.EqualFold(name[:len(prefix)], prefix) { | |
| 217 | 207 | return false | |
| 218 | 208 | } | |
| @@ -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 out | 108 | 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 false | 113 | 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 true | 118 | return true | ||
| @@ -125,20 +121,17 @@ func (r runner) generatedMockExists(interfaceName string) bool { | |||||
| 125 | return false | 121 | 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 false | 207 | 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 @@ | |||
| 1 | 1 | 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 | 5 | type UserRepository interface { | |
| 6 | 6 | Find() (*sql.Row, error) // want "no-infra-types-in-ports" | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | package port | 1 | 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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "fmt" | |
| 7 | + | "io" | |
| 6 | 8 | "os" | |
| 7 | 9 | "path/filepath" | |
| 8 | 10 | "sort" | |
| @@ -90,8 +92,10 @@ func Load(path string) (*Config, error) { | |||
| 90 | 92 | return nil, err | |
| 91 | 93 | } | |
| 92 | 94 | 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) | |
| 95 | 99 | } | |
| 96 | 100 | if hasExplicitVersion(data) && cfg.Version == 0 { | |
| 97 | 101 | return nil, errors.New("version: unsupported value 0") | |
| @@ -185,7 +189,10 @@ func hasExplicitVersion(data []byte) bool { | |||
| 185 | 189 | func DefaultRuleSeverities() map[string]Severity { | |
| 186 | 190 | return map[string]Severity{ | |
| 187 | 191 | "no-adapter-imports-in-core": SeverityError, | |
| 192 | + | "no-usecase-imports-in-core": SeverityError, | |
| 188 | 193 | "no-infra-imports-in-usecase": SeverityError, | |
| 194 | + | "no-infra-imports-in-ports": SeverityError, | |
| 195 | + | "no-entrypoint-imports-in-adapter": SeverityError, | |
| 189 | 196 | "no-framework-types-in-core": SeverityError, | |
| 190 | 197 | "no-infra-types-in-ports": SeverityError, | |
| 191 | 198 | "no-adapter-to-adapter-imports": SeverityWarn, | |
| @@ -238,17 +245,29 @@ func (c *Config) Validate() error { | |||
| 238 | 245 | default: | |
| 239 | 246 | return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high") | |
| 240 | 247 | } | |
| 248 | + | known := DefaultRuleSeverities() | |
| 241 | 249 | for rule, severity := range c.Rules { | |
| 250 | + | if _, ok := known[rule]; !ok { | |
| 251 | + | return fmt.Errorf("rules.%s: unknown rule", rule) | |
| 252 | + | } | |
| 242 | 253 | switch severity { | |
| 243 | 254 | case SeverityOff, SeverityWarn, SeverityError: | |
| 244 | 255 | default: | |
| 245 | 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 | 264 | for i, allow := range c.Allow { | |
| 249 | 265 | if allow.Rule == "" || allow.Path == "" || allow.Reason == "" { | |
| 250 | 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 | 272 | return nil | |
| 254 | 273 | } | |
| @@ -1,8 +1,10 @@ | |||||
| 1 | package config | 1 | 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, err | 92 | return nil, err | ||
| 91 | } | 93 | } | ||
| 92 | var cfg Config | 94 | var cfg Config | ||
| 93 | - | if err := yaml.Unmarshal(data, &cfg); err != nil { | 95 | + | decoder := yaml.NewDecoder(bytes.NewReader(data)) |
| 94 | - | return nil, err | 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) | |||
| 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 nil | 272 | return nil | ||
| 254 | } | 273 | } | ||
M config/config_test.go +35 -0
| @@ -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 | 85 | name: "invalid role", | |
| 51 | 86 | 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: 1 | 86 | yaml: `version: 1 | ||