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