v0.1.0 79118a8

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	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}

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