main ae76d4b

config_test.go RawHistoryFolder

1package config
2
3import (
4	"os"
5	"path/filepath"
6	"testing"
7)
8
9func TestLoad(t *testing.T) {
10	tests := []struct {
11		name    string
12		yaml    string
13		wantErr bool
14		check   func(t *testing.T, cfg *Config)
15	}{
16		{
17			name: "valid config",
18			yaml: `version: 1
19components:
20  core:
21    paths: [internal/domain/**]
22    role: core
23  adapters:
24    paths: [internal/infrastructure/**]
25    role: adapter
26rules:
27  no-adapter-imports-in-core: error
28allow:
29  - rule: no-local-fakes-for-ports
30    path: internal/foo_test.go
31    reason: stateful fake
32`,
33			check: func(t *testing.T, cfg *Config) {
34				t.Helper()
35				if cfg.Version != 1 {
36					t.Fatalf("Version = %d", cfg.Version)
37				}
38				if cfg.Components["core"].Role != RoleCore {
39					t.Fatalf("core role = %q", cfg.Components["core"].Role)
40				}
41				if cfg.Severity("no-adapter-imports-in-core") != SeverityError {
42					t.Fatalf("severity not parsed")
43				}
44				if !cfg.IsAllowed("no-local-fakes-for-ports", "internal/foo_test.go") {
45					t.Fatalf("allow entry not matched")
46				}
47			},
48		},
49		{
50			name:    "unknown top-level key",
51			yaml:    "version: 1\ncomponent: {}\n",
52			wantErr: true,
53		},
54		{
55			name:    "unknown nested key",
56			yaml:    "version: 1\nheuristics:\n  businessLogicTreshold: 3\n",
57			wantErr: true,
58		},
59		{
60			name:    "unknown rule name",
61			yaml:    "version: 1\nrules:\n  no-adaptr-imports-in-core: error\n",
62			wantErr: true,
63		},
64		{
65			name:    "unknown rule in ruleSettings",
66			yaml:    "version: 1\nruleSettings:\n  typo-rule:\n    excludePaths: [cmd/**]\n",
67			wantErr: true,
68		},
69		{
70			name:    "unknown rule in allow",
71			yaml:    "version: 1\nallow:\n  - rule: typo-rule\n    path: cmd/**\n    reason: x\n",
72			wantErr: true,
73		},
74		{
75			name: "empty file uses defaults",
76			yaml: "",
77			check: func(t *testing.T, cfg *Config) {
78				t.Helper()
79				if cfg.Severity("no-infra-imports-in-ports") != SeverityError {
80					t.Fatalf("default severity not applied")
81				}
82			},
83		},
84		{
85			name: "invalid role",
86			yaml: `version: 1
87components:
88  bad:
89    paths: [internal/**]
90    role: wat
91`,
92			wantErr: true,
93		},
94	}
95
96	for _, tt := range tests {
97		t.Run(tt.name, func(t *testing.T) {
98			dir := t.TempDir()
99			path := filepath.Join(dir, ".hexcheck.yaml")
100			if err := os.WriteFile(path, []byte(tt.yaml), 0o600); err != nil {
101				t.Fatal(err)
102			}
103			cfg, err := Load(path)
104			if tt.wantErr {
105				if err == nil {
106					t.Fatal("expected error")
107				}
108				return
109			}
110			if err != nil {
111				t.Fatal(err)
112			}
113			if tt.check != nil {
114				tt.check(t, cfg)
115			}
116		})
117	}
118}
119
120func TestLoadRejectsExplicitZeroVersion(t *testing.T) {
121	tests := []struct {
122		name string
123		yaml string
124	}{
125		{name: "zero", yaml: "version: 0\n"},
126		{name: "positive zero", yaml: "version: +0\n"},
127		{name: "double zero", yaml: "version: 00\n"},
128	}
129	for _, tt := range tests {
130		t.Run(tt.name, func(t *testing.T) {
131			dir := t.TempDir()
132			path := filepath.Join(dir, ".hexcheck.yaml")
133			data := []byte(tt.yaml + `components:
134  adapters:
135    paths: [internal/infrastructure/**]
136    role: adapter
137`)
138			if err := os.WriteFile(path, data, 0o600); err != nil {
139				t.Fatal(err)
140			}
141			if _, err := Load(path); err == nil {
142				t.Fatal("expected explicit zero version to be rejected")
143			}
144		})
145	}
146}
147
148func TestLoadHonorsExplicitExcludeTestFilesFalse(t *testing.T) {
149	dir := t.TempDir()
150	path := filepath.Join(dir, ".hexcheck.yaml")
151	data := []byte(`version: 1
152components:
153  adapters:
154    paths: [internal/infrastructure/**]
155    role: adapter
156heuristics:
157  excludeTestFiles: false
158`)
159	if err := os.WriteFile(path, data, 0o600); err != nil {
160		t.Fatal(err)
161	}
162	cfg, err := Load(path)
163	if err != nil {
164		t.Fatal(err)
165	}
166	if cfg.Heuristics.ExcludeTestFiles == nil || *cfg.Heuristics.ExcludeTestFiles {
167		t.Fatal("excludeTestFiles=false was not preserved")
168	}
169	if !filepath.IsAbs(cfg.Root) {
170		t.Fatalf("Root = %q, want absolute path", cfg.Root)
171	}
172}
173
174func TestValidateRejectsInvalidHeuristicKnobs(t *testing.T) {
175	tests := []struct {
176		name   string
177		mutate func(*Config)
178	}{
179		{name: "negative threshold", mutate: func(c *Config) { c.Heuristics.BusinessLogicThreshold = intPtr(-1) }},
180		{name: "negative strong", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinStrongSignals = intPtr(-1) }},
181		{name: "negative weak", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinWeakSignals = intPtr(-1) }},
182		{name: "zero max nodes", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxFunctionNodes = intPtr(0) }},
183		{name: "zero max diagnostics", mutate: func(c *Config) { c.Heuristics.BusinessLogicMaxDiagnosticsPerPackage = intPtr(0) }},
184		{name: "invalid business logic mode", mutate: func(c *Config) { c.Heuristics.BusinessLogicMode = "loud" }},
185		{name: "invalid business logic min confidence", mutate: func(c *Config) { c.Heuristics.BusinessLogicMinConfidence = "sure" }},
186	}
187	for _, tt := range tests {
188		t.Run(tt.name, func(t *testing.T) {
189			cfg := &Config{Version: 1, Components: map[string]Component{"adapters": {Role: RoleAdapter, Paths: []string{"internal/**"}}}}
190			cfg.applyDefaults()
191			tt.mutate(cfg)
192			if err := cfg.Validate(); err == nil {
193				t.Fatal("expected validation error")
194			}
195		})
196	}
197}
198
199func TestComponentForPathPrecedence(t *testing.T) {
200	cfg := &Config{Components: map[string]Component{
201		"all":     {Role: RoleCore, Paths: []string{"internal/**"}},
202		"adapter": {Role: RoleAdapter, Paths: []string{"internal/infrastructure/**"}},
203		"ignore":  {Role: RoleIgnore, Paths: []string{"internal/infrastructure/generated/**"}},
204	}}
205	cfg.applyDefaults()
206
207	tests := []struct {
208		name string
209		path string
210		want Role
211	}{
212		{name: "specific adapter beats broad core", path: "internal/infrastructure/postgres/repo.go", want: RoleAdapter},
213		{name: "ignore beats adapter", path: "internal/infrastructure/generated/foo.go", want: RoleIgnore},
214	}
215	for _, tt := range tests {
216		t.Run(tt.name, func(t *testing.T) {
217			match, ok := cfg.ComponentForPath(tt.path)
218			if !ok || match.Role != tt.want {
219				t.Fatalf("match = %#v, %v; want role %q", match, ok, tt.want)
220			}
221		})
222	}
223}

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