1package config
2
3import (
4 "bytes"
5 "errors"
6 "fmt"
7 "io"
8 "os"
9 "path/filepath"
10 "sort"
11
12 "github.com/bnema/hexcheck/internal/glob"
13 "gopkg.in/yaml.v3"
14)
15
16type Role string
17
18const (
19 RoleCore Role = "core"
20 RoleUsecase Role = "usecase"
21 RolePorts Role = "ports"
22 RoleAdapter Role = "adapter"
23 RoleEntrypoint Role = "entrypoint"
24 RoleIgnore Role = "ignore"
25)
26
27type Severity string
28
29const (
30 SeverityOff Severity = "off"
31 SeverityWarn Severity = "warn"
32 SeverityError Severity = "error"
33)
34
35type Config struct {
36 Version int `yaml:"version"`
37 Components map[string]Component `yaml:"components"`
38 Rules map[string]Severity `yaml:"rules"`
39 ExternalTypes ExternalTypes `yaml:"externalTypes"`
40 Heuristics Heuristics `yaml:"heuristics"`
41 Mocking Mocking `yaml:"mocking"`
42 RuleSettings map[string]RuleSetting `yaml:"ruleSettings"`
43 Allow []Allow `yaml:"allow"`
44 Root string `yaml:"-"`
45}
46
47type Component struct {
48 Role Role `yaml:"role"`
49 Paths []string `yaml:"paths"`
50}
51
52type ExternalTypes struct {
53 FrameworkPackages []string `yaml:"frameworkPackages"`
54 AdapterTypePackages []string `yaml:"adapterTypePackages"`
55}
56
57type Heuristics struct {
58 BusinessLogicThreshold *int `yaml:"businessLogicThreshold"`
59 BusinessLogicMinStrongSignals *int `yaml:"businessLogicMinStrongSignals"`
60 BusinessLogicMinWeakSignals *int `yaml:"businessLogicMinWeakSignals"`
61 BusinessLogicMaxFunctionNodes *int `yaml:"businessLogicMaxFunctionNodes"`
62 BusinessLogicMaxDiagnosticsPerPackage *int `yaml:"businessLogicMaxDiagnosticsPerPackage"`
63 BusinessLogicMode string `yaml:"businessLogicMode"`
64 BusinessLogicMinConfidence string `yaml:"businessLogicMinConfidence"`
65 BusinessKeywords []string `yaml:"businessKeywords"`
66 ExcludeTestFiles *bool `yaml:"excludeTestFiles"`
67}
68
69type Mocking struct {
70 GeneratedMockPaths []string `yaml:"generatedMockPaths"`
71 GeneratedMockNamePatterns []string `yaml:"generatedMockNamePatterns"`
72}
73
74type RuleSetting struct {
75 ExcludePaths []string `yaml:"excludePaths"`
76}
77
78type Allow struct {
79 Rule string `yaml:"rule"`
80 Path string `yaml:"path"`
81 Reason string `yaml:"reason"`
82}
83
84type Match struct {
85 Name string
86 Role Role
87}
88
89func Load(path string) (*Config, error) {
90 data, err := os.ReadFile(path)
91 if err != nil {
92 return nil, err
93 }
94 var cfg Config
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)
99 }
100 if hasExplicitVersion(data) && cfg.Version == 0 {
101 return nil, errors.New("version: unsupported value 0")
102 }
103 root, err := filepath.Abs(filepath.Dir(path))
104 if err != nil {
105 return nil, err
106 }
107 cfg.Root = root
108 cfg.applyDefaults()
109 if err := cfg.Validate(); err != nil {
110 return nil, err
111 }
112 return &cfg, nil
113}
114
115func Default() *Config {
116 cfg := &Config{Version: 1}
117 cfg.applyDefaults()
118 return cfg
119}
120
121func (c *Config) ApplyDefaults() {
122 c.applyDefaults()
123}
124
125func (c *Config) applyDefaults() {
126 if c.Version == 0 {
127 c.Version = 1
128 }
129 if c.Rules == nil {
130 c.Rules = map[string]Severity{}
131 }
132 for rule, sev := range DefaultRuleSeverities() {
133 if _, ok := c.Rules[rule]; !ok {
134 c.Rules[rule] = sev
135 }
136 }
137 if c.Heuristics.BusinessLogicThreshold == nil {
138 c.Heuristics.BusinessLogicThreshold = intPtr(8)
139 }
140 if c.Heuristics.BusinessLogicMinStrongSignals == nil {
141 c.Heuristics.BusinessLogicMinStrongSignals = intPtr(2)
142 }
143 if c.Heuristics.BusinessLogicMinWeakSignals == nil {
144 c.Heuristics.BusinessLogicMinWeakSignals = intPtr(2)
145 }
146 if c.Heuristics.BusinessLogicMaxFunctionNodes == nil {
147 c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(2000)
148 }
149 if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage == nil {
150 c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(10)
151 }
152 if c.Heuristics.ExcludeTestFiles == nil {
153 defaultExcludeTestFiles := true
154 c.Heuristics.ExcludeTestFiles = &defaultExcludeTestFiles
155 }
156 if c.Heuristics.BusinessLogicMode == "" {
157 c.Heuristics.BusinessLogicMode = "audit"
158 }
159 if c.Heuristics.BusinessLogicMinConfidence == "" {
160 c.Heuristics.BusinessLogicMinConfidence = "medium"
161 }
162 if len(c.Heuristics.BusinessKeywords) == 0 {
163 c.Heuristics.BusinessKeywords = []string{"Validate", "Authorize", "Compute", "Calculate", "Apply", "Transition", "Can", "Detect", "Migrate", "Resolve", "Profile", "Score", "Ranking", "Restore", "Purge", "Update", "Performance", "Selected"}
164 }
165 if len(c.Mocking.GeneratedMockNamePatterns) == 0 {
166 c.Mocking.GeneratedMockNamePatterns = []string{"Mock{{Interface}}", "{{Interface}}Mock"}
167 }
168}
169
170func intPtr(v int) *int { return &v }
171
172func hasExplicitVersion(data []byte) bool {
173 var node yaml.Node
174 if err := yaml.Unmarshal(data, &node); err != nil || len(node.Content) == 0 {
175 return false
176 }
177 mapping := node.Content[0]
178 if mapping.Kind != yaml.MappingNode {
179 return false
180 }
181 for i := 0; i+1 < len(mapping.Content); i += 2 {
182 if mapping.Content[i].Value == "version" {
183 return true
184 }
185 }
186 return false
187}
188
189func DefaultRuleSeverities() map[string]Severity {
190 return map[string]Severity{
191 "no-adapter-imports-in-core": SeverityError,
192 "no-usecase-imports-in-core": SeverityError,
193 "no-infra-imports-in-usecase": SeverityError,
194 "no-infra-imports-in-ports": SeverityError,
195 "no-entrypoint-imports-in-adapter": SeverityError,
196 "no-framework-types-in-core": SeverityError,
197 "no-infra-types-in-ports": SeverityError,
198 "no-adapter-to-adapter-imports": SeverityWarn,
199 "suspicious-business-logic-in-adapter": SeverityWarn,
200 "unused-ignore-directive": SeverityWarn,
201 "business-logic-diagnostics-hidden": SeverityWarn,
202 "no-local-fakes-for-ports": SeverityWarn,
203 "missing-generated-mock-for-port": SeverityWarn,
204 "prefer-generated-mocks": SeverityWarn,
205 }
206}
207
208func (c *Config) Validate() error {
209 if c.Version != 1 {
210 return fmt.Errorf("version: unsupported value %d", c.Version)
211 }
212 for name, component := range c.Components {
213 if name == "" {
214 return errors.New("components: empty component name")
215 }
216 switch component.Role {
217 case RoleCore, RoleUsecase, RolePorts, RoleAdapter, RoleEntrypoint, RoleIgnore:
218 default:
219 return fmt.Errorf("components.%s.role: unsupported role %q", name, component.Role)
220 }
221 if len(component.Paths) == 0 {
222 return fmt.Errorf("components.%s.paths: must not be empty", name)
223 }
224 }
225 if c.Heuristics.BusinessLogicThreshold != nil && *c.Heuristics.BusinessLogicThreshold < 0 {
226 return errors.New("heuristics.businessLogicThreshold: must be >= 0")
227 }
228 if c.Heuristics.BusinessLogicMinStrongSignals != nil && *c.Heuristics.BusinessLogicMinStrongSignals < 0 {
229 return errors.New("heuristics.businessLogicMinStrongSignals: must be >= 0")
230 }
231 if c.Heuristics.BusinessLogicMinWeakSignals != nil && *c.Heuristics.BusinessLogicMinWeakSignals < 0 {
232 return errors.New("heuristics.businessLogicMinWeakSignals: must be >= 0")
233 }
234 if c.Heuristics.BusinessLogicMaxFunctionNodes != nil && *c.Heuristics.BusinessLogicMaxFunctionNodes < 1 {
235 return errors.New("heuristics.businessLogicMaxFunctionNodes: must be >= 1")
236 }
237 if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 {
238 return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1")
239 }
240 switch c.Heuristics.BusinessLogicMode {
241 case "ci", "audit":
242 default:
243 return errors.New("heuristics.businessLogicMode: must be ci or audit")
244 }
245 switch c.Heuristics.BusinessLogicMinConfidence {
246 case "low", "medium", "high":
247 default:
248 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
249 }
250 known := DefaultRuleSeverities()
251 for rule, severity := range c.Rules {
252 if _, ok := known[rule]; !ok {
253 return fmt.Errorf("rules.%s: unknown rule", rule)
254 }
255 switch severity {
256 case SeverityOff, SeverityWarn, SeverityError:
257 default:
258 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
259 }
260 }
261 for rule := range c.RuleSettings {
262 if _, ok := known[rule]; !ok {
263 return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
264 }
265 }
266 for i, allow := range c.Allow {
267 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
268 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
269 }
270 if _, ok := known[allow.Rule]; !ok {
271 return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
272 }
273 }
274 return nil
275}
276
277func (c *Config) Severity(rule string) Severity {
278 if c == nil || c.Rules == nil {
279 return SeverityOff
280 }
281 return c.Rules[rule]
282}
283
284func (c *Config) RuleEnabled(rule string) bool {
285 return c.Severity(rule) != SeverityOff
286}
287
288func (c *Config) ComponentForPath(path string) (Match, bool) {
289 type candidate struct {
290 name string
291 role Role
292 spec int
293 ord int
294 }
295 var candidates []candidate
296 keys := make([]string, 0, len(c.Components))
297 for key := range c.Components {
298 keys = append(keys, key)
299 }
300 sort.Strings(keys)
301 for ord, name := range keys {
302 component := c.Components[name]
303 for _, pattern := range component.Paths {
304 if glob.Match(pattern, path) {
305 candidates = append(candidates, candidate{name: name, role: component.Role, spec: glob.Specificity(pattern), ord: ord})
306 }
307 }
308 }
309 if len(candidates) == 0 {
310 return Match{}, false
311 }
312 sort.SliceStable(candidates, func(i, j int) bool {
313 if candidates[i].role == RoleIgnore && candidates[j].role != RoleIgnore {
314 return true
315 }
316 if candidates[j].role == RoleIgnore && candidates[i].role != RoleIgnore {
317 return false
318 }
319 if candidates[i].spec != candidates[j].spec {
320 return candidates[i].spec > candidates[j].spec
321 }
322 return candidates[i].ord < candidates[j].ord
323 })
324 best := candidates[0]
325 return Match{Name: best.name, Role: best.role}, true
326}
327
328func (c *Config) IsAllowed(rule, path string) bool {
329 for _, allow := range c.Allow {
330 if allow.Rule == rule && glob.Match(allow.Path, path) {
331 return true
332 }
333 }
334 return false
335}
336
337func DiscoverConfig(start string) (string, error) {
338 start, err := filepath.Abs(start)
339 if err != nil {
340 return "", err
341 }
342 info, err := os.Stat(start)
343 if err == nil && !info.IsDir() {
344 start = filepath.Dir(start)
345 }
346 for dir := start; ; dir = filepath.Dir(dir) {
347 candidate := filepath.Join(dir, ".hexcheck.yaml")
348 if _, err := os.Stat(candidate); err == nil {
349 return candidate, nil
350 }
351 if parent := filepath.Dir(dir); parent == dir {
352 break
353 }
354 }
355 return "", os.ErrNotExist
356}