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}