1package main_test
2
3import (
4 "fmt"
5 "go/ast"
6 "go/parser"
7 "go/token"
8 "io/fs"
9 "path/filepath"
10 "sort"
11 "strconv"
12 "strings"
13 "testing"
14)
15
16const modulePath = "github.com/bnema/vev"
17
18type packageLayer string
19
20const (
21 layerRoot packageLayer = "root"
22 layerCommand packageLayer = "command"
23 layerScript packageLayer = "script"
24 layerApp packageLayer = "app"
25 layerAdapter packageLayer = "adapter"
26 layerUsecase packageLayer = "usecase"
27 layerPorts packageLayer = "ports"
28 layerProtocol packageLayer = "protocol"
29 layerCatalogue packageLayer = "catalogue"
30 layerWire packageLayer = "wire"
31 layerDomain packageLayer = "domain"
32 layerPersist packageLayer = "persist"
33 layerPlatform packageLayer = "platform"
34 layerLogging packageLayer = "logging"
35 layerPkg packageLayer = "pkg"
36 layerTestSupport packageLayer = "test-support"
37)
38
39var productionDependencies = map[packageLayer]map[packageLayer]bool{
40 layerRoot: {layerApp: true},
41 layerCommand: {layerPkg: true},
42 layerScript: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPkg: true},
43 layerApp: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerUsecase: true, layerPersist: true, layerPlatform: true, layerLogging: true, layerPkg: true},
44 layerAdapter: {layerAdapter: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true, layerWire: true, layerPlatform: true, layerPkg: true},
45 layerUsecase: {layerUsecase: true, layerDomain: true, layerPorts: true, layerProtocol: true, layerCatalogue: true},
46 layerPorts: {layerDomain: true, layerProtocol: true, layerCatalogue: true},
47 layerProtocol: {layerDomain: true},
48 layerCatalogue: {layerDomain: true, layerProtocol: true},
49 layerWire: {layerDomain: true, layerProtocol: true},
50 layerDomain: {layerDomain: true},
51 layerPersist: {layerDomain: true, layerPorts: true, layerProtocol: true, layerPkg: true},
52 layerPlatform: {},
53 layerLogging: {layerPkg: true},
54 layerPkg: {layerPkg: true},
55}
56
57func TestImportBoundaries(t *testing.T) {
58 violations, packages := inspectRepositoryDependencies(t)
59 if len(violations) != 0 {
60 t.Fatalf("invalid package dependencies:\n%s", strings.Join(violations, "\n"))
61 }
62 if len(packages) == 0 {
63 t.Fatal("package inventory is empty")
64 }
65}
66
67// externalDependencyOwners confines technology imports to their owning
68// layers. Use cases never touch protobuf runtime APIs, generated wire
69// envelopes, QUIC, stream framing, or concrete adapters; the wire package
70// never touches QUIC either (carriage lives in adapters).
71var externalDependencyOwners = map[string][]packageLayer{
72 "google.golang.org/protobuf/": {layerAdapter, layerApp, layerWire, layerTestSupport, layerScript},
73 "github.com/quic-go/quic-go": {layerAdapter, layerApp},
74}
75
76func TestExternalDependencyBoundaries(t *testing.T) {
77 violations := inspectExternalDependencies(t)
78 if len(violations) != 0 {
79 t.Fatalf("invalid external dependencies:\n%s", strings.Join(violations, "\n"))
80 }
81}
82
83func TestImportBoundaryNegativeFixtures(t *testing.T) {
84 tests := []struct {
85 name, source, target string
86 testFile bool
87 want bool
88 }{
89 {"usecase accepts semantic protocol", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol", false, true},
90 {"usecase rejects wire", modulePath + "/internal/usecase/daemon", modulePath + "/internal/protocol/wire", false, false},
91 {"usecase rejects adapter", modulePath + "/internal/usecase/client", modulePath + "/internal/adapters/ipc", false, false},
92 {"domain rejects ports", modulePath + "/internal/domain", modulePath + "/internal/ports", false, false},
93 {"pkg rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", false, false},
94 {"snapshot adapter exception", modulePath + "/internal/adapters/snapshot", modulePath + "/internal/usecase/snapshot", false, true},
95 {"other adapter rejects usecase", modulePath + "/internal/adapters/ipc", modulePath + "/internal/usecase/client", false, false},
96 {"usecase test may use wire fixture", modulePath + "/internal/usecase/client", modulePath + "/internal/protocol/wire", true, true},
97 {"pkg test still rejects internal", modulePath + "/pkg/rawterm", modulePath + "/internal/domain", true, false},
98 // P1.1 broker ownership: broker consumes ports/protocol/domain
99 // (catalogue counts as protocol family) and no sibling use case.
100 {"broker accepts ports", modulePath + "/internal/usecase/broker", modulePath + "/internal/ports", false, true},
101 {"broker accepts protocol", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol", false, true},
102 {"broker accepts domain", modulePath + "/internal/usecase/broker", modulePath + "/internal/domain", false, true},
103 {"broker accepts catalogue", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/catalogue", false, true},
104 {"broker accepts own subpackage", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/broker/streams", false, true},
105 {"broker subpackage accepts broker root", modulePath + "/internal/usecase/broker/streams", modulePath + "/internal/usecase/broker", false, true},
106 {"broker rejects client", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/client", false, false},
107 {"broker rejects daemon", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/daemon", false, false},
108 {"broker rejects sibling usecase", modulePath + "/internal/usecase/broker", modulePath + "/internal/usecase/picker", false, false},
109 {"broker rejects adapter", modulePath + "/internal/usecase/broker", modulePath + "/internal/adapters/ipc", false, false},
110 {"broker rejects wire", modulePath + "/internal/usecase/broker", modulePath + "/internal/protocol/wire", false, false},
111 {"broker rejects app", modulePath + "/internal/usecase/broker", modulePath + "/internal/app", false, false},
112 {"broker rejects persist", modulePath + "/internal/usecase/broker", modulePath + "/internal/persist", false, false},
113 // P1.1 client/daemon must not own broker, each other, or remote discovery.
114 {"client rejects daemon", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/daemon", false, false},
115 {"client rejects broker", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/broker", false, false},
116 {"daemon rejects client", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/client", false, false},
117 {"daemon rejects broker", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/broker", false, false},
118 {"daemon rejects app", modulePath + "/internal/usecase/daemon", modulePath + "/internal/app", false, false},
119 }
120 for _, tt := range tests {
121 t.Run(tt.name, func(t *testing.T) {
122 got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
123 if err != nil {
124 t.Fatalf("dependencyAllowed: %v", err)
125 }
126 if got != tt.want {
127 t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
128 }
129 })
130 }
131}
132
133func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
134 t.Helper()
135 var violations []string
136 packages := make(map[string]struct{})
137 fset := token.NewFileSet()
138 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
139 if walkErr != nil {
140 return walkErr
141 }
142 if entry.IsDir() {
143 name := entry.Name()
144 if name == ".git" || name == ".worktrees" || name == "vendor" {
145 return filepath.SkipDir
146 }
147 return nil
148 }
149 if !strings.HasSuffix(path, ".go") {
150 return nil
151 }
152 source := importPathForFile(path)
153 packages[source] = struct{}{}
154 if _, err := classifyPackage(source); err != nil {
155 violations = append(violations, path+": "+err.Error())
156 return nil
157 }
158 file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
159 if err != nil {
160 return err
161 }
162 testFile := strings.HasSuffix(path, "_test.go")
163 for _, spec := range file.Imports {
164 target, err := strconv.Unquote(spec.Path.Value)
165 if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) {
166 continue
167 }
168 allowed, err := dependencyAllowed(source, target, testFile)
169 if err != nil {
170 violations = append(violations, fmt.Sprintf("%s: %v", path, err))
171 continue
172 }
173 if !allowed {
174 violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
175 }
176 }
177 return nil
178 })
179 if err != nil {
180 t.Fatalf("walking repository: %v", err)
181 }
182 sort.Strings(violations)
183 return violations, packages
184}
185
186func dependencyAllowed(source, target string, testFile bool) (bool, error) {
187 sourceLayer, err := classifyPackage(source)
188 if err != nil {
189 return false, err
190 }
191 targetLayer, err := classifyPackage(target)
192 if err != nil {
193 return false, err
194 }
195 if testFile {
196 return sourceLayer != layerPkg, nil
197 }
198 if sourceLayer == layerTestSupport {
199 return testSupportDependencyAllowed(source, targetLayer), nil
200 }
201 if source == modulePath+"/internal/adapters/snapshot" && target == modulePath+"/internal/usecase/snapshot" {
202 return true, nil
203 }
204 if packageImportDenied(source, target) {
205 return false, nil
206 }
207 return productionDependencies[sourceLayer][targetLayer], nil
208}
209
210// packageImportDenied encodes ADR 001 ownership at package granularity for
211// production files. Layer rules alone permit any usecase-to-usecase import;
212// other, and keep the broker free of every sibling use case. Test files stay
213// exempt so composition tests can wire adapters and orchestrators together.
214func packageImportDenied(source, target string) bool {
215 brokerPkg := modulePath + "/internal/usecase/broker"
216 clientPkg := modulePath + "/internal/usecase/client"
217 daemonPkg := modulePath + "/internal/usecase/daemon"
218 usecasePrefix := modulePath + "/internal/usecase/"
219 under := func(path, root string) bool {
220 return path == root || strings.HasPrefix(path, root+"/")
221 }
222 switch {
223 case under(source, brokerPkg):
224 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
225 case under(source, clientPkg):
226 return under(target, daemonPkg) || under(target, brokerPkg)
227 case under(source, daemonPkg):
228 return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg)
229 default:
230 return false
231 }
232}
233
234func testSupportDependencyAllowed(source string, target packageLayer) bool {
235 switch {
236 case source == modulePath+"/internal/ports/mocks":
237 return target == layerPorts || target == layerDomain || target == layerProtocol || target == layerCatalogue
238 case source == modulePath+"/internal/protocol/wire/mocks":
239 return target == layerWire
240 case strings.HasPrefix(source, modulePath+"/internal/testutil"):
241 return target == layerDomain || target == layerProtocol || target == layerWire
242 default:
243 return false
244 }
245}
246
247func classifyPackage(path string) (packageLayer, error) {
248 switch {
249 case path == modulePath:
250 return layerRoot, nil
251 case strings.HasPrefix(path, modulePath+"/cmd/"):
252 return layerCommand, nil
253 case strings.HasPrefix(path, modulePath+"/scripts/"):
254 return layerScript, nil
255 case path == modulePath+"/internal/app":
256 return layerApp, nil
257 case strings.HasPrefix(path, modulePath+"/internal/adapters/"):
258 return layerAdapter, nil
259 case path == modulePath+"/internal/ports/mocks", path == modulePath+"/internal/protocol/wire/mocks", strings.HasPrefix(path, modulePath+"/internal/testutil"):
260 return layerTestSupport, nil
261 case path == modulePath+"/internal/ports":
262 return layerPorts, nil
263 case path == modulePath+"/internal/protocol/wire":
264 return layerWire, nil
265 case path == modulePath+"/internal/protocol/catalogue":
266 return layerCatalogue, nil
267 case path == modulePath+"/internal/protocol":
268 return layerProtocol, nil
269 case path == modulePath+"/internal/domain" || strings.HasPrefix(path, modulePath+"/internal/domain/"):
270 return layerDomain, nil
271 case path == modulePath+"/internal/usecase" || strings.HasPrefix(path, modulePath+"/internal/usecase/"):
272 return layerUsecase, nil
273 case path == modulePath+"/internal/persist":
274 return layerPersist, nil
275 case path == modulePath+"/internal/platform":
276 return layerPlatform, nil
277 case path == modulePath+"/internal/logging":
278 return layerLogging, nil
279 case path == modulePath+"/pkg" || strings.HasPrefix(path, modulePath+"/pkg/"):
280 return layerPkg, nil
281 default:
282 return "", fmt.Errorf("unclassified package %q", path)
283 }
284}
285
286func importPathForFile(path string) string {
287 dir := filepath.ToSlash(filepath.Dir(path))
288 if dir == "." {
289 return modulePath
290 }
291 return modulePath + "/" + strings.TrimPrefix(dir, "./")
292}
293
294func inspectExternalDependencies(t *testing.T) []string {
295 t.Helper()
296 var violations []string
297 fset := token.NewFileSet()
298 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
299 if walkErr != nil {
300 return walkErr
301 }
302 if entry.IsDir() {
303 name := entry.Name()
304 if name == ".git" || name == ".worktrees" || name == "vendor" {
305 return filepath.SkipDir
306 }
307 return nil
308 }
309 if !strings.HasSuffix(path, ".go") {
310 return nil
311 }
312 source := importPathForFile(path)
313 sourceLayer, err := classifyPackage(source)
314 if err != nil {
315 violations = append(violations, path+": "+err.Error())
316 return nil
317 }
318 // Test files may use any external dependency except inside pkg,
319 // mirroring the internal test-import policy.
320 if strings.HasSuffix(path, "_test.go") && sourceLayer != layerPkg {
321 return nil
322 }
323 file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
324 if err != nil {
325 return err
326 }
327 for _, spec := range file.Imports {
328 target, err := strconv.Unquote(spec.Path.Value)
329 if err != nil {
330 continue
331 }
332 for prefix, owners := range externalDependencyOwners {
333 if !strings.HasPrefix(target, prefix) {
334 continue
335 }
336 allowed := false
337 for _, owner := range owners {
338 if sourceLayer == owner {
339 allowed = true
340 break
341 }
342 }
343 if !allowed {
344 violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
345 }
346 }
347 }
348 return nil
349 })
350 if err != nil {
351 t.Fatalf("walking repository: %v", err)
352 }
353 sort.Strings(violations)
354 return violations
355}
356
357// TestProductionDaemonBindingComposition pins the daemon authority composition
358// at the source level: the daemon's process binding may only be built by the one
359// production site that provisions the daemon's closed policy set, and the
360// single-policy shorthand must not reappear outside test fixtures.
361//
362// The multi-policy constructor exists because a daemon serves one exact policy
363// per accepted carriage shape (its own local Unix daemonmux policy, plus each
364// provisioned remote transport policy). A production composition that used the
365// single-policy shorthand would publish an authority that refuses every remote
366// carriage, so the composition is pinned to the multi-policy call, and the
367// shorthand stays a fixtures-only convenience.
368func TestProductionDaemonBindingComposition(t *testing.T) {
369 const runFile = "internal/app/run.go"
370
371 var (
372 bindingSites []string
373 runBindings int
374 )
375 fset := token.NewFileSet()
376 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
377 if walkErr != nil {
378 return walkErr
379 }
380 name := entry.Name()
381 if entry.IsDir() {
382 if name == ".git" || name == ".worktrees" || name == "vendor" {
383 return filepath.SkipDir
384 }
385 return nil
386 }
387 if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
388 return nil
389 }
390 file, err := parser.ParseFile(fset, path, nil, 0)
391 if err != nil {
392 return err
393 }
394 slashed := filepath.ToSlash(path)
395 for _, decl := range file.Decls {
396 fn, ok := decl.(*ast.FuncDecl)
397 if !ok || fn.Body == nil {
398 continue
399 }
400 ast.Inspect(fn.Body, func(node ast.Node) bool {
401 call, ok := node.(*ast.CallExpr)
402 if !ok {
403 return true
404 }
405 selector, ok := call.Fun.(*ast.SelectorExpr)
406 if !ok {
407 return true
408 }
409 // Only the adapter's own constructors count: a method spelled
410 // the same way elsewhere is unrelated.
411 ident, ok := selector.X.(*ast.Ident)
412 if !ok || ident.Name != "daemonmux" {
413 return true
414 }
415 switch selector.Sel.Name {
416 case "NewServerBinding":
417 bindingSites = append(bindingSites, fmt.Sprintf("%s:%d: %s must build the closed policy set with NewServerBindings", slashed, fset.Position(call.Pos()).Line, selector.Sel.Name))
418 case "NewServerBindings":
419 if slashed == runFile {
420 runBindings++
421 }
422 }
423 return true
424 })
425 }
426 return nil
427 })
428 if err != nil {
429 t.Fatalf("walking repository: %v", err)
430 }
431 if len(bindingSites) != 0 {
432 t.Fatalf("production code uses the single-policy daemon binding shorthand:\n%s", strings.Join(bindingSites, "\n"))
433 }
434 if runBindings == 0 {
435 t.Fatalf("%s must compose the daemon authority with daemonmux.NewServerBindings", runFile)
436 }
437}