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