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