Organize D&D extensions by domain
This commit is contained in:
203
internal/modules/import_boundaries_test.go
Normal file
203
internal/modules/import_boundaries_test.go
Normal file
@@ -0,0 +1,203 @@
|
||||
package modules_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const moduleImportPrefix = "gitea.maximumdirect.net/eric/notarius/internal/modules/"
|
||||
|
||||
func TestProductionImportBoundaries(t *testing.T) {
|
||||
repositoryRoot := testRepositoryRoot(t)
|
||||
err := filepath.WalkDir(repositoryRoot, func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == ".git" || entry.Name() == "testdata" || entry.Name() == "vendor" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
return checkImportBoundaries(repositoryRoot, path)
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportBoundaryFixtureIsRejected(t *testing.T) {
|
||||
repositoryRoot := testRepositoryRoot(t)
|
||||
fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go")
|
||||
err := checkImportBoundaries(repositoryRoot, fixture)
|
||||
if err == nil {
|
||||
t.Fatal("fixture import was accepted, want generic-to-D&D violation")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "generic packages must not import D&D packages") {
|
||||
t.Fatalf("fixture error = %q, want generic-to-D&D violation", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportBoundaryRules(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
filename string
|
||||
importPath string
|
||||
wantError bool
|
||||
}{
|
||||
{
|
||||
name: "D&D implementation cannot import Seriatim",
|
||||
filename: "internal/modules/dnd/extract/example/extractor.go",
|
||||
importPath: moduleImportPrefix + "seriatim/input/transcript",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "Seriatim implementation cannot import D&D",
|
||||
filename: "internal/modules/seriatim/input/example/adapter.go",
|
||||
importPath: moduleImportPrefix + "dnd/shared",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "generic implementation cannot import D&D",
|
||||
filename: "internal/modules/generic/merge/example/merger.go",
|
||||
importPath: moduleImportPrefix + "dnd/shared",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "domain root cannot import child",
|
||||
filename: "internal/modules/dnd/types.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
wantError: true,
|
||||
},
|
||||
{
|
||||
name: "D&D implementation may import generic implementation",
|
||||
filename: "internal/modules/dnd/extract/example/extractor.go",
|
||||
importPath: moduleImportPrefix + "generic/normalize/noop",
|
||||
},
|
||||
{
|
||||
name: "domain registrar may compose child packages",
|
||||
filename: "internal/modules/dnd/register/register.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
},
|
||||
{
|
||||
name: "CLI may compose registrars",
|
||||
filename: "internal/cli/catalog.go",
|
||||
importPath: moduleImportPrefix + "dnd/register",
|
||||
},
|
||||
{
|
||||
name: "external integration test may compose domains",
|
||||
filename: "internal/modules/integration/example_test.go",
|
||||
importPath: moduleImportPrefix + "dnd/extract/spells",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateImport(tt.filename, tt.importPath)
|
||||
if tt.wantError && err == nil {
|
||||
t.Fatal("validateImport() error = nil, want boundary violation")
|
||||
}
|
||||
if !tt.wantError && err != nil {
|
||||
t.Fatalf("validateImport() error = %v, want nil", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func checkImportBoundaries(repositoryRoot string, filename string) error {
|
||||
parsed, err := parser.ParseFile(token.NewFileSet(), filename, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse %s: %w", filename, err)
|
||||
}
|
||||
relative, err := filepath.Rel(repositoryRoot, filename)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve relative path for %s: %w", filename, err)
|
||||
}
|
||||
relative = filepath.ToSlash(relative)
|
||||
for _, imported := range parsed.Imports {
|
||||
importPath, err := strconv.Unquote(imported.Path.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse import in %s: %w", relative, err)
|
||||
}
|
||||
if err := validateImport(relative, importPath); err != nil {
|
||||
return fmt.Errorf("%s imports %s: %w", relative, importPath, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateImport(filename string, importPath string) error {
|
||||
if isExternalIntegrationTest(filename) {
|
||||
return nil
|
||||
}
|
||||
sourceDomain, sourceRoot := domainForFile(filename)
|
||||
targetDomain, targetChild := domainForImport(importPath)
|
||||
if sourceDomain == "" || targetDomain == "" {
|
||||
return nil
|
||||
}
|
||||
if sourceRoot && sourceDomain == targetDomain && targetChild {
|
||||
return fmt.Errorf("domain root packages must not import child implementations")
|
||||
}
|
||||
if sourceDomain == "generic" && targetDomain == "dnd" {
|
||||
return fmt.Errorf("generic packages must not import D&D packages")
|
||||
}
|
||||
if sourceDomain == "dnd" && targetDomain == "seriatim" {
|
||||
return fmt.Errorf("D&D packages must not import Seriatim packages")
|
||||
}
|
||||
if sourceDomain == "seriatim" && targetDomain == "dnd" {
|
||||
return fmt.Errorf("Seriatim packages must not import D&D packages")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func domainForFile(filename string) (domain string, root bool) {
|
||||
const prefix = "internal/modules/"
|
||||
if !strings.HasPrefix(filename, prefix) {
|
||||
return "", false
|
||||
}
|
||||
remainder := strings.TrimPrefix(filename, prefix)
|
||||
parts := strings.Split(remainder, "/")
|
||||
if len(parts) < 2 || !isDomain(parts[0]) {
|
||||
return "", false
|
||||
}
|
||||
return parts[0], len(parts) == 2
|
||||
}
|
||||
|
||||
func domainForImport(importPath string) (domain string, child bool) {
|
||||
if !strings.HasPrefix(importPath, moduleImportPrefix) {
|
||||
return "", false
|
||||
}
|
||||
remainder := strings.TrimPrefix(importPath, moduleImportPrefix)
|
||||
parts := strings.Split(remainder, "/")
|
||||
if len(parts) == 0 || !isDomain(parts[0]) {
|
||||
return "", false
|
||||
}
|
||||
return parts[0], len(parts) > 1
|
||||
}
|
||||
|
||||
func isDomain(name string) bool {
|
||||
return name == "dnd" || name == "generic" || name == "seriatim"
|
||||
}
|
||||
|
||||
func isExternalIntegrationTest(filename string) bool {
|
||||
return strings.HasPrefix(filename, "internal/modules/integration/") && strings.HasSuffix(filename, "_test.go")
|
||||
}
|
||||
|
||||
func testRepositoryRoot(t *testing.T) string {
|
||||
t.Helper()
|
||||
_, filename, _, ok := runtime.Caller(0)
|
||||
if !ok {
|
||||
t.Fatal("resolve import-boundary test location")
|
||||
}
|
||||
return filepath.Clean(filepath.Join(filepath.Dir(filename), "..", ".."))
|
||||
}
|
||||
Reference in New Issue
Block a user