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 // Shared color-profile policy is a leaf use case both sides may import.
120 {"client accepts colorprofile", modulePath + "/internal/usecase/client", modulePath + "/internal/usecase/colorprofile", false, true},
121 {"daemon accepts colorprofile", modulePath + "/internal/usecase/daemon", modulePath + "/internal/usecase/colorprofile", false, true},
122 }
123 for _, tt := range tests {
124 t.Run(tt.name, func(t *testing.T) {
125 got, err := dependencyAllowed(tt.source, tt.target, tt.testFile)
126 if err != nil {
127 t.Fatalf("dependencyAllowed: %v", err)
128 }
129 if got != tt.want {
130 t.Fatalf("dependencyAllowed(%q, %q, test=%t) = %t, want %t", tt.source, tt.target, tt.testFile, got, tt.want)
131 }
132 })
133 }
134}
135
136func inspectRepositoryDependencies(t *testing.T) ([]string, map[string]struct{}) {
137 t.Helper()
138 var violations []string
139 packages := make(map[string]struct{})
140 fset := token.NewFileSet()
141 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
142 if walkErr != nil {
143 return walkErr
144 }
145 if entry.IsDir() {
146 name := entry.Name()
147 if name == ".git" || name == ".worktrees" || name == "vendor" {
148 return filepath.SkipDir
149 }
150 return nil
151 }
152 if !strings.HasSuffix(path, ".go") {
153 return nil
154 }
155 source := importPathForFile(path)
156 packages[source] = struct{}{}
157 if _, err := classifyPackage(source); err != nil {
158 violations = append(violations, path+": "+err.Error())
159 return nil
160 }
161 file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
162 if err != nil {
163 return err
164 }
165 testFile := strings.HasSuffix(path, "_test.go")
166 for _, spec := range file.Imports {
167 target, err := strconv.Unquote(spec.Path.Value)
168 if err != nil || (target != modulePath && !strings.HasPrefix(target, modulePath+"/")) {
169 continue
170 }
171 allowed, err := dependencyAllowed(source, target, testFile)
172 if err != nil {
173 violations = append(violations, fmt.Sprintf("%s: %v", path, err))
174 continue
175 }
176 if !allowed {
177 violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
178 }
179 }
180 return nil
181 })
182 if err != nil {
183 t.Fatalf("walking repository: %v", err)
184 }
185 sort.Strings(violations)
186 return violations, packages
187}
188
189func dependencyAllowed(source, target string, testFile bool) (bool, error) {
190 sourceLayer, err := classifyPackage(source)
191 if err != nil {
192 return false, err
193 }
194 targetLayer, err := classifyPackage(target)
195 if err != nil {
196 return false, err
197 }
198 if testFile {
199 return sourceLayer != layerPkg, nil
200 }
201 if sourceLayer == layerTestSupport {
202 return testSupportDependencyAllowed(source, targetLayer), nil
203 }
204 if source == modulePath+"/internal/adapters/snapshot" && target == modulePath+"/internal/usecase/snapshot" {
205 return true, nil
206 }
207 if packageImportDenied(source, target) {
208 return false, nil
209 }
210 return productionDependencies[sourceLayer][targetLayer], nil
211}
212
213// packageImportDenied encodes ADR 001 ownership at package granularity for
214// production files. Layer rules alone permit any usecase-to-usecase import;
215// other, and keep the broker free of every sibling use case. Test files stay
216// exempt so composition tests can wire adapters and orchestrators together.
217func packageImportDenied(source, target string) bool {
218 brokerPkg := modulePath + "/internal/usecase/broker"
219 clientPkg := modulePath + "/internal/usecase/client"
220 daemonPkg := modulePath + "/internal/usecase/daemon"
221 usecasePrefix := modulePath + "/internal/usecase/"
222 under := func(path, root string) bool {
223 return path == root || strings.HasPrefix(path, root+"/")
224 }
225 switch {
226 case under(source, brokerPkg):
227 return !under(target, brokerPkg) && strings.HasPrefix(target, usecasePrefix)
228 case under(source, clientPkg):
229 return under(target, daemonPkg) || under(target, brokerPkg)
230 case under(source, daemonPkg):
231 return under(target, clientPkg) || under(target, daemonPkg) || under(target, brokerPkg)
232 default:
233 return false
234 }
235}
236
237func testSupportDependencyAllowed(source string, target packageLayer) bool {
238 switch {
239 case source == modulePath+"/internal/ports/mocks":
240 return target == layerPorts || target == layerDomain || target == layerProtocol || target == layerCatalogue
241 case source == modulePath+"/internal/protocol/wire/mocks":
242 return target == layerWire
243 case strings.HasPrefix(source, modulePath+"/internal/testutil"):
244 return target == layerDomain || target == layerProtocol || target == layerWire
245 default:
246 return false
247 }
248}
249
250func classifyPackage(path string) (packageLayer, error) {
251 switch {
252 case path == modulePath:
253 return layerRoot, nil
254 case strings.HasPrefix(path, modulePath+"/cmd/"):
255 return layerCommand, nil
256 case strings.HasPrefix(path, modulePath+"/scripts/"):
257 return layerScript, nil
258 case path == modulePath+"/internal/app":
259 return layerApp, nil
260 case strings.HasPrefix(path, modulePath+"/internal/adapters/"):
261 return layerAdapter, nil
262 case path == modulePath+"/internal/ports/mocks", path == modulePath+"/internal/protocol/wire/mocks", strings.HasPrefix(path, modulePath+"/internal/testutil"):
263 return layerTestSupport, nil
264 case path == modulePath+"/internal/ports":
265 return layerPorts, nil
266 case path == modulePath+"/internal/protocol/wire":
267 return layerWire, nil
268 case path == modulePath+"/internal/protocol/catalogue":
269 return layerCatalogue, nil
270 case path == modulePath+"/internal/protocol":
271 return layerProtocol, nil
272 case path == modulePath+"/internal/domain" || strings.HasPrefix(path, modulePath+"/internal/domain/"):
273 return layerDomain, nil
274 case path == modulePath+"/internal/usecase" || strings.HasPrefix(path, modulePath+"/internal/usecase/"):
275 return layerUsecase, nil
276 case path == modulePath+"/internal/persist":
277 return layerPersist, nil
278 case path == modulePath+"/internal/platform":
279 return layerPlatform, nil
280 case path == modulePath+"/internal/logging":
281 return layerLogging, nil
282 case path == modulePath+"/pkg" || strings.HasPrefix(path, modulePath+"/pkg/"):
283 return layerPkg, nil
284 default:
285 return "", fmt.Errorf("unclassified package %q", path)
286 }
287}
288
289func importPathForFile(path string) string {
290 dir := filepath.ToSlash(filepath.Dir(path))
291 if dir == "." {
292 return modulePath
293 }
294 return modulePath + "/" + strings.TrimPrefix(dir, "./")
295}
296
297func inspectExternalDependencies(t *testing.T) []string {
298 t.Helper()
299 var violations []string
300 fset := token.NewFileSet()
301 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
302 if walkErr != nil {
303 return walkErr
304 }
305 if entry.IsDir() {
306 name := entry.Name()
307 if name == ".git" || name == ".worktrees" || name == "vendor" {
308 return filepath.SkipDir
309 }
310 return nil
311 }
312 if !strings.HasSuffix(path, ".go") {
313 return nil
314 }
315 source := importPathForFile(path)
316 sourceLayer, err := classifyPackage(source)
317 if err != nil {
318 violations = append(violations, path+": "+err.Error())
319 return nil
320 }
321 // Test files may use any external dependency except inside pkg,
322 // mirroring the internal test-import policy.
323 if strings.HasSuffix(path, "_test.go") && sourceLayer != layerPkg {
324 return nil
325 }
326 file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly)
327 if err != nil {
328 return err
329 }
330 for _, spec := range file.Imports {
331 target, err := strconv.Unquote(spec.Path.Value)
332 if err != nil {
333 continue
334 }
335 for prefix, owners := range externalDependencyOwners {
336 if !strings.HasPrefix(target, prefix) {
337 continue
338 }
339 allowed := false
340 for _, owner := range owners {
341 if sourceLayer == owner {
342 allowed = true
343 break
344 }
345 }
346 if !allowed {
347 violations = append(violations, fmt.Sprintf("%s: %s may not import %s", path, source, target))
348 }
349 }
350 }
351 return nil
352 })
353 if err != nil {
354 t.Fatalf("walking repository: %v", err)
355 }
356 sort.Strings(violations)
357 return violations
358}
359
360// TestProductionDaemonBindingComposition pins the daemon authority composition
361// at the source level: the daemon's process binding may only be built by the one
362// production site that provisions the daemon's closed policy set, and the
363// single-policy shorthand must not reappear outside test fixtures.
364//
365// The multi-policy constructor exists because a daemon serves one exact policy
366// per accepted carriage shape (its own local Unix daemonmux policy, plus each
367// provisioned remote transport policy). A production composition that used the
368// single-policy shorthand would publish an authority that refuses every remote
369// carriage, so the composition is pinned to the multi-policy call, and the
370// shorthand stays a fixtures-only convenience.
371func TestProductionDaemonBindingComposition(t *testing.T) {
372 const runFile = "internal/app/daemon_run.go"
373
374 var (
375 bindingSites []string
376 runBindings int
377 )
378 fset := token.NewFileSet()
379 err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
380 if walkErr != nil {
381 return walkErr
382 }
383 name := entry.Name()
384 if entry.IsDir() {
385 if name == ".git" || name == ".worktrees" || name == "vendor" {
386 return filepath.SkipDir
387 }
388 return nil
389 }
390 if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
391 return nil
392 }
393 file, err := parser.ParseFile(fset, path, nil, 0)
394 if err != nil {
395 return err
396 }
397 slashed := filepath.ToSlash(path)
398 for _, decl := range file.Decls {
399 fn, ok := decl.(*ast.FuncDecl)
400 if !ok || fn.Body == nil {
401 continue
402 }
403 ast.Inspect(fn.Body, func(node ast.Node) bool {
404 call, ok := node.(*ast.CallExpr)
405 if !ok {
406 return true
407 }
408 selector, ok := call.Fun.(*ast.SelectorExpr)
409 if !ok {
410 return true
411 }
412 // Only the adapter's own constructors count: a method spelled
413 // the same way elsewhere is unrelated.
414 ident, ok := selector.X.(*ast.Ident)
415 if !ok || ident.Name != "daemonmux" {
416 return true
417 }
418 switch selector.Sel.Name {
419 case "NewServerBinding":
420 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))
421 case "NewServerBindings":
422 if slashed == runFile {
423 runBindings++
424 }
425 }
426 return true
427 })
428 }
429 return nil
430 })
431 if err != nil {
432 t.Fatalf("walking repository: %v", err)
433 }
434 if len(bindingSites) != 0 {
435 t.Fatalf("production code uses the single-policy daemon binding shorthand:\n%s", strings.Join(bindingSites, "\n"))
436 }
437 if runBindings == 0 {
438 t.Fatalf("%s must compose the daemon authority with daemonmux.NewServerBindings", runFile)
439 }
440}