v0.8.1 b8e893f

boundary_test.go RawHistoryFolder

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3// Package boundary_test enforces the import rules between the layers of
4// ermite. The module root holds no Go package (the binary lives in cmd/ermite),
5// so the check lives in this test-only package and walks the module from its
6// root. It parses import clauses only; it never loads packages.
7package boundary_test
8
9import (
10	"fmt"
11	"go/parser"
12	"go/token"
13	"io/fs"
14	"path/filepath"
15	"sort"
16	"strconv"
17	"strings"
18	"testing"
19)
20
21const modulePath = "bnema.dev/ermite"
22
23// apiGenPath is the generated code of the API contract (protobuf and Connect),
24// a module of its own.
25const apiGenPath = "bnema.dev/ermite-api/gen"
26
27// moduleRoot is the module root relative to this package's directory.
28const moduleRoot = "../.."
29
30type packageLayer string
31
32const (
33	layerCommand     packageLayer = "command"
34	layerApp         packageLayer = "app"
35	layerUsecase     packageLayer = "usecase"
36	layerAdapter     packageLayer = "adapter"
37	layerClientLimit packageLayer = "clientlimit"
38	layerConfig      packageLayer = "config"
39	layerPorts       packageLayer = "ports"
40	layerDomain      packageLayer = "domain"
41	layerGenerated   packageLayer = "generated"
42	layerScript      packageLayer = "script"
43	layerBoundary    packageLayer = "boundary"
44	layerTestSupport packageLayer = "test-support"
45)
46
47// productionDependencies lists, per layer, the layers its production files
48// may import.
49//
50//   - cmd/ermite is the entry point: it only calls internal/app.
51//   - internal/app is the composition root and the CLI: it wires everything.
52//   - internal/usecase holds the use cases: domain and ports only.
53//   - adapters implement ports and never import use cases or app; the
54//     per-package rule in packageImportDenied also keeps them from importing
55//     each other, and keeps the web adapter off the generated API code: like
56//     every other adapter it reads and writes through the ports (ADR 0020).
57//   - internal/clientlimit is infrastructure shared by the HTTP adapters.
58//   - internal/config parses the environment into plain values.
59var productionDependencies = map[packageLayer]map[packageLayer]bool{
60	layerCommand:     {layerApp: true},
61	layerApp:         {layerUsecase: true, layerAdapter: true, layerClientLimit: true, layerConfig: true, layerPorts: true, layerDomain: true, layerGenerated: true},
62	layerUsecase:     {layerUsecase: true, layerPorts: true, layerDomain: true},
63	layerAdapter:     {layerAdapter: true, layerClientLimit: true, layerPorts: true, layerDomain: true, layerGenerated: true},
64	layerClientLimit: {layerPorts: true},
65	layerConfig:      {},
66	layerPorts:       {layerDomain: true},
67	layerDomain:      {layerDomain: true},
68	layerGenerated:   {layerGenerated: true},
69	layerScript:      {},
70	layerBoundary:    {},
71	layerTestSupport: {layerPorts: true, layerDomain: true},
72}
73
74func TestImportBoundaries(t *testing.T) {
75	violations, packages := inspectRepositoryDependencies(t)
76	if len(violations) != 0 {
77		t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
78	}
79	for _, want := range []string{"cmd/ermite", "internal/app", "internal/usecase", "internal/ports", "internal/domain"} {
80		if _, ok := packages[modulePath+"/"+want]; !ok {
81			t.Fatalf("package inventory misses %s: the walk is not reading the module", want)
82		}
83	}
84}
85
86// The generated API code is outside the module: the walk would skip it. The
87// web adapter may not import it in production code, whichever package of it.
88func TestWebAdapterRejectsAPIGeneratedCode(t *testing.T) {
89	tests := []struct {
90		name, source, target string
91		testFile             bool
92		want                 bool
93	}{
94		{"web messages", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", false, false},
95		{"web connect", modulePath + "/internal/adapters/web", apiGenPath + "/ermite/ermiteconnect", false, false},
96		{"web subpackage", modulePath + "/internal/adapters/web/sub", apiGenPath + "/ermite", false, false},
97		{"web test file", modulePath + "/internal/adapters/web", apiGenPath + "/ermite", true, true},
98		{"rpc adapter", modulePath + "/internal/adapters/rpc", apiGenPath + "/ermite", false, true},
99		{"client adapter", modulePath + "/internal/adapters/client", apiGenPath + "/ermite/ermiteconnect", false, true},
100		{"other module of the API", modulePath + "/internal/adapters/web", "bnema.dev/ermite-api/other", false, true},
101		{"web other external package", modulePath + "/internal/adapters/web", "github.com/a-h/templ", false, true},
102		{"a package whose name only starts alike", modulePath + "/internal/adapters/web", apiGenPath + "x/y", false, true},
103	}
104	for _, tt := range tests {
105		t.Run(tt.name, func(t *testing.T) {
106			if got := !externalImportDenied(tt.source, tt.target, tt.testFile); got != tt.want {
107				t.Fatalf("externalImportDenied(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, !got, !tt.want)
108			}
109		})
110	}
111}
112
113func TestImportBoundaryFixtures(t *testing.T) {
114	p := func(s string) string { return modulePath + "/" + s }
115	tests := []struct {
116		name, source, target string
117		testFile             bool
118		want                 bool
119	}{
120		{"command accepts app", p("cmd/ermite"), p("internal/app"), false, true},
121		{"command rejects usecase", p("cmd/ermite"), p("internal/usecase"), false, false},
122		{"command rejects adapter", p("cmd/ermite"), p("internal/adapters/web"), false, false},
123		{"command rejects domain", p("cmd/ermite"), p("internal/domain"), false, false},
124		{"app accepts usecase", p("internal/app"), p("internal/usecase"), false, true},
125		{"app accepts adapter", p("internal/app"), p("internal/adapters/gitcli"), false, true},
126		{"app accepts config", p("internal/app"), p("internal/config"), false, true},
127		{"app accepts clientlimit", p("internal/app"), p("internal/clientlimit"), false, true},
128		{"app rejects command", p("internal/app"), p("cmd/ermite"), false, false},
129		{"app test may import command", p("internal/app"), p("cmd/ermite"), true, true},
130		{"usecase accepts domain", p("internal/usecase"), p("internal/domain"), false, true},
131		{"usecase accepts ports", p("internal/usecase"), p("internal/ports"), false, true},
132		{"usecase rejects adapter", p("internal/usecase"), p("internal/adapters/kvstore"), false, false},
133		{"usecase rejects app", p("internal/usecase"), p("internal/app"), false, false},
134		{"usecase rejects config", p("internal/usecase"), p("internal/config"), false, false},
135		{"usecase rejects clientlimit", p("internal/usecase"), p("internal/clientlimit"), false, false},
136		{"usecase test may import adapter", p("internal/usecase"), p("internal/adapters/kvstore"), true, true},
137		{"adapter accepts ports", p("internal/adapters/rpc"), p("internal/ports"), false, true},
138		{"adapter accepts domain", p("internal/adapters/rpc"), p("internal/domain"), false, true},
139		{"adapter accepts clientlimit", p("internal/adapters/githttp"), p("internal/clientlimit"), false, true},
140		{"adapter accepts own subpackage", p("internal/adapters/web/sub"), p("internal/adapters/web"), false, true},
141		{"adapter accepts generated", p("internal/adapters/rpc"), p("gen/ermite"), false, true},
142		{"web rejects generated", p("internal/adapters/web"), p("gen/ermite"), false, false},
143		{"web rejects generated connect", p("internal/adapters/web"), p("gen/ermite/ermiteconnect"), false, false},
144		{"web subpackage rejects generated", p("internal/adapters/web/sub"), p("gen/ermite"), false, false},
145		{"web test may import ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), true, true},
146		{"web production rejects ports mocks", p("internal/adapters/web"), p("internal/ports/mocks"), false, false},
147		{"adapter rejects usecase", p("internal/adapters/rpc"), p("internal/usecase"), false, false},
148		{"adapter rejects app", p("internal/adapters/rpc"), p("internal/app"), false, false},
149		{"adapter rejects config", p("internal/adapters/rpc"), p("internal/config"), false, false},
150		{"adapter rejects sibling adapter", p("internal/adapters/web"), p("internal/adapters/gitcli"), false, false},
151		{"adapter test may import sibling adapter", p("internal/adapters/ermitegit"), p("internal/adapters/gitcli"), true, true},
152		{"clientlimit accepts ports", p("internal/clientlimit"), p("internal/ports"), false, true},
153		{"clientlimit rejects usecase", p("internal/clientlimit"), p("internal/usecase"), false, false},
154		{"config rejects domain", p("internal/config"), p("internal/domain"), false, false},
155		{"ports accepts domain", p("internal/ports"), p("internal/domain"), false, true},
156		{"ports rejects usecase", p("internal/ports"), p("internal/usecase"), false, false},
157		{"ports rejects adapter", p("internal/ports"), p("internal/adapters/web"), false, false},
158		{"ports mocks accept ports", p("internal/ports/mocks"), p("internal/ports"), false, true},
159		{"ports mocks reject usecase", p("internal/ports/mocks"), p("internal/usecase"), false, false},
160		{"reader conformance accepts ports", p("internal/readertest"), p("internal/ports"), false, true},
161		{"reader conformance rejects adapter", p("internal/readertest"), p("internal/adapters/gitcli"), false, false},
162		{"domain accepts domain", p("internal/domain"), p("internal/domain/x"), false, true},
163		{"domain rejects ports", p("internal/domain"), p("internal/ports"), false, false},
164		{"domain test may import anything", p("internal/domain"), p("internal/ports"), true, true},
165	}
166	for _, tt := range tests {
167		t.Run(tt.name, func(t *testing.T) {
168			got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
169			if err != nil {
170				t.Fatalf("dependencyAllowed: %v", err)
171			}
172			if got != tt.want {
173				t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
174			}
175		})
176	}
177}
178
179func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
180	t.Helper()
181	var violations []string
182	packages := make(map[string]struct{})
183	fset := token.NewFileSet()
184	err := filepath.WalkDir(moduleRoot, func(path string, entry fs.DirEntry, walkErr error) error {
185		if walkErr != nil {
186			return walkErr
187		}
188		if entry.IsDir() {
189			name := entry.Name()
190			if name == ".git" || name == ".worktrees" || name == "vendor" || name == "testdata" || name == "node_modules" {
191				return filepath.SkipDir
192			}
193			return nil
194		}
195		if !strings.HasSuffix(path, ".go") {
196			return nil
197		}
198		rel, err := filepath.Rel(moduleRoot, path)
199		if err != nil {
200			return err
201		}
202		source := importPathForFile(rel)
203		packages[source] = struct{}{}
204		if _, err := classifyPackage(source); err != nil {
205			violations = append(violations, rel+": "+err.Error())
206			return nil
207		}
208		file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
209		if err != nil {
210			return err
211		}
212		testFile := strings.HasSuffix(path, "_test.go")
213		for _, spec := range file.Imports {
214			target, err := strconv.Unquote(spec.Path.Value)
215			if err != nil {
216				continue
217			}
218			if externalImportDenied(source, target, testFile) {
219				violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target))
220				continue
221			}
222			if target != modulePath && !strings.HasPrefix(target, modulePath+"/") {
223				continue
224			}
225			allowed, err := dependencyAllowed(source, target, testFile)
226			if err != nil {
227				violations = append(violations, fmt.Sprintf("%s: %v", rel, err))
228				continue
229			}
230			if !allowed {
231				violations = append(violations, fmt.Sprintf("%s: %s may not import %s", rel, source, target))
232			}
233		}
234		return nil
235	})
236	if err != nil {
237		t.Fatalf("walking module: %v", err)
238	}
239	sort.Strings(violations)
240	return violations, packages
241}
242
243// dependencyAllowed reports whether a file of package source may import
244// target. Test files are exempt, so that composition tests can wire adapters
245// and use cases together; their packages must still be classified.
246func dependencyAllowed(source, target string, testFile bool) (bool, error) {
247	sourceLayer, err := classifyPackage(source)
248	if err != nil {
249		return false, err
250	}
251	targetLayer, err := classifyPackage(target)
252	if err != nil {
253		return false, err
254	}
255	if testFile {
256		return true, nil
257	}
258	if packageImportDenied(source, target) {
259		return false, nil
260	}
261	return productionDependencies[sourceLayer][targetLayer], nil
262}
263
264// externalImportDenied reports whether a file of package source may not import
265// target, a package outside the module. The only such rule: the production
266// code of the web adapter does not import the generated API code
267// (apiGenPath), so that it goes through the ports. Test files are exempt, like
268// for every rule here.
269func externalImportDenied(source, target string, testFile bool) bool {
270	if testFile || !under(target, apiGenPath) {
271		return false
272	}
273	return under(source, modulePath+"/internal/adapters/web")
274}
275
276// packageImportDenied narrows the layer rules to packages: an adapter may
277// import only packages under its own directory, never a sibling adapter; and
278// the web adapter may not import the generated API code at all (protobuf,
279// Connect): it goes through the ports, as the other adapters that serve the
280// use cases do.
281func packageImportDenied(source, target string) bool {
282	const adapters = modulePath + "/internal/adapters/"
283	if strings.HasPrefix(source, adapters) && strings.HasPrefix(target, adapters) {
284		return adapterRoot(source) != adapterRoot(target)
285	}
286	if strings.HasPrefix(source, adapters) && adapterRoot(source) == "web" {
287		return under(target, modulePath+"/gen")
288	}
289	return false
290}
291
292// adapterRoot is the first path element under internal/adapters.
293func adapterRoot(path string) string {
294	rest := strings.TrimPrefix(path, modulePath+"/internal/adapters/")
295	root, _, _ := strings.Cut(rest, "/")
296	return root
297}
298
299func under(path, root string) bool {
300	return path == root || strings.HasPrefix(path, root+"/")
301}
302
303func classifyPackage(path string) (packageLayer, error) {
304	p := func(s string) string { return modulePath + "/" + s }
305	switch {
306	case under(path, p("cmd")):
307		return layerCommand, nil
308	case under(path, p("scripts")):
309		return layerScript, nil
310	case under(path, p("internal/boundary")):
311		return layerBoundary, nil
312	case under(path, p("internal/app")):
313		return layerApp, nil
314	case under(path, p("internal/usecase")):
315		return layerUsecase, nil
316	case path == p("internal/ports/mocks"), under(path, p("internal/readertest")):
317		return layerTestSupport, nil
318	case under(path, p("internal/adapters")):
319		return layerAdapter, nil
320	case under(path, p("internal/clientlimit")):
321		return layerClientLimit, nil
322	case under(path, p("internal/config")):
323		return layerConfig, nil
324	case path == p("internal/ports"):
325		return layerPorts, nil
326	case under(path, p("internal/domain")):
327		return layerDomain, nil
328	case under(path, p("gen")):
329		return layerGenerated, nil
330	default:
331		return "", fmt.Errorf("unclassified package %q", path)
332	}
333}
334
335func importPathForFile(rel string) string {
336	dir := filepath.ToSlash(filepath.Dir(rel))
337	if dir == "." {
338		return modulePath
339	}
340	return modulePath + "/" + dir
341}

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 3

Tags 43

↑↓ moveenter openesc close