v0.35.1 2aca1df

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

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 4

Tags 45

↑↓ moveenter openesc close