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}