v0.2.0 92ac699

config.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 1

Tags 5

↑↓ moveenter openesc close