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