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 "no-local-fakes-for-ports": SeverityWarn,
201 "missing-generated-mock-for-port": SeverityWarn,
202 "prefer-generated-mocks": SeverityWarn,
203 }
204}
205
206func (c *Config) Validate() error {
207 if c.Version != 1 {
208 return fmt.Errorf("version: unsupported value %d", c.Version)
209 }
210 for name, component := range c.Components {
211 if name == "" {
212 return errors.New("components: empty component name")
213 }
214 switch component.Role {
215 case RoleCore, RoleUsecase, RolePorts, RoleAdapter, RoleEntrypoint, RoleIgnore:
216 default:
217 return fmt.Errorf("components.%s.role: unsupported role %q", name, component.Role)
218 }
219 if len(component.Paths) == 0 {
220 return fmt.Errorf("components.%s.paths: must not be empty", name)
221 }
222 }
223 if c.Heuristics.BusinessLogicThreshold != nil && *c.Heuristics.BusinessLogicThreshold < 0 {
224 return errors.New("heuristics.businessLogicThreshold: must be >= 0")
225 }
226 if c.Heuristics.BusinessLogicMinStrongSignals != nil && *c.Heuristics.BusinessLogicMinStrongSignals < 0 {
227 return errors.New("heuristics.businessLogicMinStrongSignals: must be >= 0")
228 }
229 if c.Heuristics.BusinessLogicMinWeakSignals != nil && *c.Heuristics.BusinessLogicMinWeakSignals < 0 {
230 return errors.New("heuristics.businessLogicMinWeakSignals: must be >= 0")
231 }
232 if c.Heuristics.BusinessLogicMaxFunctionNodes != nil && *c.Heuristics.BusinessLogicMaxFunctionNodes < 1 {
233 return errors.New("heuristics.businessLogicMaxFunctionNodes: must be >= 1")
234 }
235 if c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage != nil && *c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage < 1 {
236 return errors.New("heuristics.businessLogicMaxDiagnosticsPerPackage: must be >= 1")
237 }
238 switch c.Heuristics.BusinessLogicMode {
239 case "ci", "audit":
240 default:
241 return errors.New("heuristics.businessLogicMode: must be ci or audit")
242 }
243 switch c.Heuristics.BusinessLogicMinConfidence {
244 case "low", "medium", "high":
245 default:
246 return errors.New("heuristics.businessLogicMinConfidence: must be low, medium, or high")
247 }
248 known := DefaultRuleSeverities()
249 for rule, severity := range c.Rules {
250 if _, ok := known[rule]; !ok {
251 return fmt.Errorf("rules.%s: unknown rule", rule)
252 }
253 switch severity {
254 case SeverityOff, SeverityWarn, SeverityError:
255 default:
256 return fmt.Errorf("rules.%s: unsupported severity %q", rule, severity)
257 }
258 }
259 for rule := range c.RuleSettings {
260 if _, ok := known[rule]; !ok {
261 return fmt.Errorf("ruleSettings.%s: unknown rule", rule)
262 }
263 }
264 for i, allow := range c.Allow {
265 if allow.Rule == "" || allow.Path == "" || allow.Reason == "" {
266 return fmt.Errorf("allow[%d]: rule, path, and reason are required", i)
267 }
268 if _, ok := known[allow.Rule]; !ok {
269 return fmt.Errorf("allow[%d].rule: unknown rule %q", i, allow.Rule)
270 }
271 }
272 return nil
273}
274
275func (c *Config) Severity(rule string) Severity {
276 if c == nil || c.Rules == nil {
277 return SeverityOff
278 }
279 return c.Rules[rule]
280}
281
282func (c *Config) RuleEnabled(rule string) bool {
283 return c.Severity(rule) != SeverityOff
284}
285
286func (c *Config) ComponentForPath(path string) (Match, bool) {
287 type candidate struct {
288 name string
289 role Role
290 spec int
291 ord int
292 }
293 var candidates []candidate
294 keys := make([]string, 0, len(c.Components))
295 for key := range c.Components {
296 keys = append(keys, key)
297 }
298 sort.Strings(keys)
299 for ord, name := range keys {
300 component := c.Components[name]
301 for _, pattern := range component.Paths {
302 if glob.Match(pattern, path) {
303 candidates = append(candidates, candidate{name: name, role: component.Role, spec: glob.Specificity(pattern), ord: ord})
304 }
305 }
306 }
307 if len(candidates) == 0 {
308 return Match{}, false
309 }
310 sort.SliceStable(candidates, func(i, j int) bool {
311 if candidates[i].role == RoleIgnore && candidates[j].role != RoleIgnore {
312 return true
313 }
314 if candidates[j].role == RoleIgnore && candidates[i].role != RoleIgnore {
315 return false
316 }
317 if candidates[i].spec != candidates[j].spec {
318 return candidates[i].spec > candidates[j].spec
319 }
320 return candidates[i].ord < candidates[j].ord
321 })
322 best := candidates[0]
323 return Match{Name: best.name, Role: best.role}, true
324}
325
326func (c *Config) IsAllowed(rule, path string) bool {
327 for _, allow := range c.Allow {
328 if allow.Rule == rule && glob.Match(allow.Path, path) {
329 return true
330 }
331 }
332 return false
333}
334
335func DiscoverConfig(start string) (string, error) {
336 start, err := filepath.Abs(start)
337 if err != nil {
338 return "", err
339 }
340 info, err := os.Stat(start)
341 if err == nil && !info.IsDir() {
342 start = filepath.Dir(start)
343 }
344 for dir := start; ; dir = filepath.Dir(dir) {
345 candidate := filepath.Join(dir, ".hexcheck.yaml")
346 if _, err := os.Stat(candidate); err == nil {
347 return candidate, nil
348 }
349 if parent := filepath.Dir(dir); parent == dir {
350 break
351 }
352 }
353 return "", os.ErrNotExist
354}