v0.8.0 cad1d13

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	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}

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