boundary_test.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 12

Tags 11

↑↓ moveenter openesc close