Enforce module family import boundaries

This commit is contained in:
2026-07-17 13:58:50 +00:00
parent 3d3cc0c08e
commit 2df7084d5d
4 changed files with 180 additions and 79 deletions

View File

@@ -189,6 +189,11 @@ Seriatim registrars own their production leaf registrations. The D&D registrar
owns D&D leaf registrations, the spell default-validator chain, and D&D owns D&D leaf registrations, the spell default-validator chain, and D&D
prompt/schema asset collection. prompt/schema asset collection.
Concrete implementation packages do not import generic implementation
packages directly. A concrete family's `register` package is its composition
point for specializing reusable generic implementations, while the generic
registrar composes only generic children.
Framework packages must not import production extensions. Tests may compose Framework packages must not import production extensions. Tests may compose
registries and catalogs directly with fakes. registries and catalogs directly with fakes.

View File

@@ -41,69 +41,145 @@ func TestImportBoundaryFixtureIsRejected(t *testing.T) {
fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go") fixture := filepath.Join(repositoryRoot, "internal", "modules", "generic", "testdata", "importboundaries", "imports_dnd.go")
err := checkImportBoundaries(repositoryRoot, fixture) err := checkImportBoundaries(repositoryRoot, fixture)
if err == nil { if err == nil {
t.Fatal("fixture import was accepted, want generic-to-D&D violation") t.Fatal("fixture import was accepted, want generic-to-concrete violation")
} }
if !strings.Contains(err.Error(), "generic packages must not import D&D packages") { if !strings.Contains(err.Error(), "generic family must not import concrete family") {
t.Fatalf("fixture error = %q, want generic-to-D&D violation", err) t.Fatalf("fixture error = %q, want generic-to-concrete violation", err)
} }
} }
func TestImportBoundaryRules(t *testing.T) { func TestImportBoundaryRules(t *testing.T) {
tests := []struct { tests := []struct {
name string name string
filename string filename string
importPath string sourcePackage string
wantError bool importPath string
wantError bool
}{ }{
{ {
name: "D&D implementation cannot import Seriatim", name: "concrete implementation cannot import peer concrete family",
filename: "internal/modules/dnd/extract/example/extractor.go", filename: "internal/modules/dnd/extract/example/extractor.go",
importPath: moduleImportPrefix + "seriatim/input/transcript", sourcePackage: "example",
wantError: true, importPath: moduleImportPrefix + "seriatim/input/transcript",
wantError: true,
}, },
{ {
name: "Seriatim implementation cannot import D&D", name: "future concrete family cannot import current concrete family",
filename: "internal/modules/seriatim/input/example/adapter.go", filename: "internal/modules/almanac/extract/example/extractor.go",
importPath: moduleImportPrefix + "dnd/shared", sourcePackage: "example",
wantError: true, importPath: moduleImportPrefix + "dnd/shared",
wantError: true,
}, },
{ {
name: "generic implementation cannot import D&D", name: "current concrete family cannot import future concrete family",
filename: "internal/modules/generic/merge/example/merger.go", filename: "internal/modules/seriatim/input/example/adapter.go",
importPath: moduleImportPrefix + "dnd/shared", sourcePackage: "example",
wantError: true, importPath: moduleImportPrefix + "almanac/shared",
wantError: true,
}, },
{ {
name: "domain root cannot import child", name: "generic implementation cannot import current concrete family",
filename: "internal/modules/dnd/types.go", filename: "internal/modules/generic/merge/example/merger.go",
importPath: moduleImportPrefix + "dnd/extract/spells", sourcePackage: "example",
wantError: true, importPath: moduleImportPrefix + "dnd/shared",
wantError: true,
}, },
{ {
name: "D&D implementation may import generic implementation", name: "generic implementation cannot import future concrete family",
filename: "internal/modules/dnd/extract/example/extractor.go", filename: "internal/modules/generic/merge/example/merger.go",
importPath: moduleImportPrefix + "generic/normalize/noop", sourcePackage: "example",
importPath: moduleImportPrefix + "almanac/shared",
wantError: true,
}, },
{ {
name: "domain registrar may compose child packages", name: "concrete implementation cannot import generic implementation",
filename: "internal/modules/dnd/register/register.go", filename: "internal/modules/dnd/extract/example/extractor.go",
importPath: moduleImportPrefix + "dnd/extract/spells", sourcePackage: "example",
importPath: moduleImportPrefix + "generic/normalize/noop",
wantError: true,
}, },
{ {
name: "CLI may compose registrars", name: "concrete white-box test follows production rules",
filename: "internal/cli/catalog.go", filename: "internal/modules/dnd/extract/example/extractor_test.go",
importPath: moduleImportPrefix + "dnd/register", sourcePackage: "example",
importPath: moduleImportPrefix + "generic/normalize/noop",
wantError: true,
}, },
{ {
name: "external integration test may compose domains", name: "concrete registrar may compose generic implementation",
filename: "internal/modules/integration/example_test.go", filename: "internal/modules/almanac/register/register.go",
importPath: moduleImportPrefix + "dnd/extract/spells", sourcePackage: "register",
importPath: moduleImportPrefix + "generic/normalize/noop",
},
{
name: "family root cannot import child implementation",
filename: "internal/modules/almanac/types.go",
sourcePackage: "almanac",
importPath: moduleImportPrefix + "almanac/extract/events",
wantError: true,
},
{
name: "family root cannot import registrar",
filename: "internal/modules/almanac/types.go",
sourcePackage: "almanac",
importPath: moduleImportPrefix + "almanac/register",
wantError: true,
},
{
name: "child may import family root",
filename: "internal/modules/almanac/extract/events/extractor.go",
sourcePackage: "events",
importPath: moduleImportPrefix + "almanac",
},
{
name: "child may import same-family sibling",
filename: "internal/modules/almanac/extract/events/extractor.go",
sourcePackage: "events",
importPath: moduleImportPrefix + "almanac/shared",
},
{
name: "concrete registrar may compose own child",
filename: "internal/modules/almanac/register/register.go",
sourcePackage: "register",
importPath: moduleImportPrefix + "almanac/extract/events",
},
{
name: "generic registrar may compose generic child",
filename: "internal/modules/generic/register/register.go",
sourcePackage: "register",
importPath: moduleImportPrefix + "generic/output/json",
},
{
name: "application composition root may compose registrars",
filename: "internal/cli/catalog.go",
sourcePackage: "cli",
importPath: moduleImportPrefix + "almanac/register",
},
{
name: "black-box integration test may compose families",
filename: "internal/modules/integration/example_test.go",
sourcePackage: "integration_test",
importPath: moduleImportPrefix + "almanac/extract/events",
},
{
name: "white-box integration test is not exempt",
filename: "internal/modules/integration/example_test.go",
sourcePackage: "integration",
importPath: moduleImportPrefix + "almanac/extract/events",
wantError: true,
},
{
name: "integration production file is not exempt",
filename: "internal/modules/integration/example.go",
sourcePackage: "integration",
importPath: moduleImportPrefix + "almanac/extract/events",
wantError: true,
}, },
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
err := validateImport(tt.filename, tt.importPath) err := validateImport(tt.filename, tt.sourcePackage, tt.importPath)
if tt.wantError && err == nil { if tt.wantError && err == nil {
t.Fatal("validateImport() error = nil, want boundary violation") t.Fatal("validateImport() error = nil, want boundary violation")
} }
@@ -129,68 +205,77 @@ func checkImportBoundaries(repositoryRoot string, filename string) error {
if err != nil { if err != nil {
return fmt.Errorf("parse import in %s: %w", relative, err) return fmt.Errorf("parse import in %s: %w", relative, err)
} }
if err := validateImport(relative, importPath); err != nil { if err := validateImport(relative, parsed.Name.Name, importPath); err != nil {
return fmt.Errorf("%s imports %s: %w", relative, importPath, err) return fmt.Errorf("%s imports %s: %w", relative, importPath, err)
} }
} }
return nil return nil
} }
func validateImport(filename string, importPath string) error { func validateImport(filename string, sourcePackage string, importPath string) error {
if isExternalIntegrationTest(filename) { if isBlackBoxIntegrationTest(filename, sourcePackage) {
return nil return nil
} }
sourceDomain, sourceRoot := domainForFile(filename) targetFamily, targetChild := moduleFamilyForImport(importPath)
targetDomain, targetChild := domainForImport(importPath) if targetFamily == "" {
if sourceDomain == "" || targetDomain == "" {
return nil return nil
} }
if sourceRoot && sourceDomain == targetDomain && targetChild { if isIntegrationFile(filename) {
return fmt.Errorf("domain root packages must not import child implementations") return fmt.Errorf("module integration composition is allowed only in black-box tests")
} }
if sourceDomain == "generic" && targetDomain == "dnd" { sourceFamily, sourceRoot, sourceRegistrar := moduleFamilyForFile(filename)
return fmt.Errorf("generic packages must not import D&D packages") if sourceFamily == "" {
return nil
} }
if sourceDomain == "dnd" && targetDomain == "seriatim" { if sourceRoot && sourceFamily == targetFamily && targetChild {
return fmt.Errorf("D&D packages must not import Seriatim packages") return fmt.Errorf("family root must not import child packages")
} }
if sourceDomain == "seriatim" && targetDomain == "dnd" { if sourceFamily == targetFamily {
return fmt.Errorf("Seriatim packages must not import D&D packages") return nil
} }
return nil if sourceFamily == "generic" {
return fmt.Errorf("generic family must not import concrete family %q", targetFamily)
}
if targetFamily == "generic" {
if sourceRegistrar {
return nil
}
return fmt.Errorf("concrete family %q may import generic implementations only from its registrar", sourceFamily)
}
return fmt.Errorf("concrete family %q must not import concrete family %q", sourceFamily, targetFamily)
} }
func domainForFile(filename string) (domain string, root bool) { func moduleFamilyForFile(filename string) (family string, root bool, registrar bool) {
const prefix = "internal/modules/" const prefix = "internal/modules/"
if !strings.HasPrefix(filename, prefix) { if !strings.HasPrefix(filename, prefix) {
return "", false return "", false, false
} }
remainder := strings.TrimPrefix(filename, prefix) remainder := strings.TrimPrefix(filename, prefix)
parts := strings.Split(remainder, "/") parts := strings.Split(remainder, "/")
if len(parts) < 2 || !isDomain(parts[0]) { if len(parts) < 2 || parts[0] == "integration" {
return "", false return "", false, false
} }
return parts[0], len(parts) == 2 return parts[0], len(parts) == 2, len(parts) > 2 && parts[1] == "register"
} }
func domainForImport(importPath string) (domain string, child bool) { func moduleFamilyForImport(importPath string) (family string, child bool) {
if !strings.HasPrefix(importPath, moduleImportPrefix) { if !strings.HasPrefix(importPath, moduleImportPrefix) {
return "", false return "", false
} }
remainder := strings.TrimPrefix(importPath, moduleImportPrefix) remainder := strings.TrimPrefix(importPath, moduleImportPrefix)
parts := strings.Split(remainder, "/") parts := strings.Split(remainder, "/")
if len(parts) == 0 || !isDomain(parts[0]) { if len(parts) == 0 || parts[0] == "" || parts[0] == "integration" {
return "", false return "", false
} }
return parts[0], len(parts) > 1 return parts[0], len(parts) > 1
} }
func isDomain(name string) bool { func isIntegrationFile(filename string) bool {
return name == "dnd" || name == "generic" || name == "seriatim" return strings.HasPrefix(filename, "internal/modules/integration/")
} }
func isExternalIntegrationTest(filename string) bool { func isBlackBoxIntegrationTest(filename string, sourcePackage string) bool {
return strings.HasPrefix(filename, "internal/modules/integration/") && strings.HasSuffix(filename, "_test.go") return isIntegrationFile(filename) && strings.HasSuffix(filename, "_test.go") && sourcePackage == "integration_test"
} }
func testRepositoryRoot(t *testing.T) string { func testRepositoryRoot(t *testing.T) string {

View File

@@ -12,8 +12,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
) )
func TestPipelineConfigLoadsAndResolvesWithSeriatimInput(t *testing.T) { func TestPipelineConfigLoadsAndResolvesWithSeriatimInput(t *testing.T) {
@@ -192,12 +190,7 @@ func mustRegisterExtractor(t *testing.T, registry *pipeline.ExtractorRegistry, s
func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) { func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := pipeline.RegisterMerger[seriatimArtifact](registry, spec, func() (contracts.Merger[seriatimArtifact], error) { if err := pipeline.RegisterMerger[seriatimArtifact](registry, spec, func() (contracts.Merger[seriatimArtifact], error) {
return appendorder.NewTyped(func(values []seriatimArtifact) (seriatimArtifact, error) { return fakeMerger{}, nil
if len(values) == 0 {
return seriatimArtifact{}, nil
}
return values[0], nil
})
}); err != nil { }); err != nil {
t.Fatalf("register merger: %v", err) t.Fatalf("register merger: %v", err)
} }
@@ -206,7 +199,7 @@ func mustRegisterMerger(t *testing.T, registry *pipeline.MergerRegistry, spec pi
func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) { func mustRegisterNormalizer(t *testing.T, registry *pipeline.NormalizerRegistry, spec pipeline.ModuleSpec) {
t.Helper() t.Helper()
if err := pipeline.RegisterNormalizer[seriatimArtifact](registry, spec, func() (contracts.Normalizer[seriatimArtifact], error) { if err := pipeline.RegisterNormalizer[seriatimArtifact](registry, spec, func() (contracts.Normalizer[seriatimArtifact], error) {
return noop.NewTyped[seriatimArtifact](), nil return fakeNormalizer{}, nil
}); err != nil { }); err != nil {
t.Fatalf("register normalizer: %v", err) t.Fatalf("register normalizer: %v", err)
} }
@@ -241,6 +234,27 @@ func (fakeExtractor) Extract(ctx context.Context, req contracts.TypedExtractionR
return contracts.TypedExtractionResult[seriatimArtifact]{}, nil return contracts.TypedExtractionResult[seriatimArtifact]{}, nil
} }
type fakeMerger struct{}
func (fakeMerger) Key() string { return pipeline.DefaultMergeModule }
func (fakeMerger) Merge(ctx context.Context, req contracts.TypedMergeRequest[seriatimArtifact]) (contracts.TypedMergeResult[seriatimArtifact], error) {
if len(req.ExtractOutputs) == 0 {
return contracts.TypedMergeResult[seriatimArtifact]{}, nil
}
return contracts.TypedMergeResult[seriatimArtifact]{Value: req.ExtractOutputs[0].Value}, nil
}
type fakeNormalizer struct{}
func (fakeNormalizer) Key() string { return pipeline.DefaultNormalizeModule }
func (fakeNormalizer) ReferenceSlots() []contracts.ReferenceSlot { return nil }
func (fakeNormalizer) Normalize(ctx context.Context, req contracts.TypedNormalizeRequest[seriatimArtifact]) (contracts.TypedNormalizeResult[seriatimArtifact], error) {
return contracts.TypedNormalizeResult[seriatimArtifact]{Value: req.MergeOutput.Value}, nil
}
type fakeOutput struct{} type fakeOutput struct{}
func (fakeOutput) Key() string { return pipeline.DefaultOutputModule } func (fakeOutput) Key() string { return pipeline.DefaultOutputModule }

View File

@@ -11,8 +11,6 @@ import (
"gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/core/source"
"gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts"
"gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline" "gitea.maximumdirect.net/eric/notarius/internal/framework/pipeline"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/merge/appendorder"
"gitea.maximumdirect.net/eric/notarius/internal/modules/generic/normalize/noop"
) )
func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) { func runPreparedPipeline(t *testing.T, registries pipeline.Registries, resolved pipeline.ResolvedPipeline, llmClient contracts.StructuredLLMClient, input pipeline.RunInput) (pipeline.RunOutput, error) {
@@ -138,15 +136,14 @@ func seriatimRunnerRegistries(t *testing.T, extractor contracts.Extractor[seriat
}); err != nil { }); err != nil {
t.Fatalf("register extractor: %v", err) t.Fatalf("register extractor: %v", err)
} }
if err := appendorder.RegisterTyped(mergers, seriatimArtifactKind, func(values []seriatimArtifact) (seriatimArtifact, error) { if err := pipeline.RegisterMerger[seriatimArtifact](mergers, pipeline.ModuleSpec{Key: pipeline.DefaultMergeModule, Stage: pipeline.StageMerge, ArtifactKind: seriatimArtifactKind}, func() (contracts.Merger[seriatimArtifact], error) {
if len(values) == 0 { return fakeMerger{}, nil
return seriatimArtifact{}, nil
}
return values[0], nil
}); err != nil { }); err != nil {
t.Fatalf("register merger: %v", err) t.Fatalf("register merger: %v", err)
} }
if err := noop.RegisterTyped[seriatimArtifact](normalizers, seriatimArtifactKind); err != nil { if err := pipeline.RegisterNormalizer[seriatimArtifact](normalizers, pipeline.ModuleSpec{Key: pipeline.DefaultNormalizeModule, Stage: pipeline.StageNormalize, ArtifactKind: seriatimArtifactKind}, func() (contracts.Normalizer[seriatimArtifact], error) {
return fakeNormalizer{}, nil
}); err != nil {
t.Fatalf("register normalizer: %v", err) t.Fatalf("register normalizer: %v", err)
} }
if err := outputs.Register(pipeline.DefaultOutputModule, func() (contracts.OutputEncoder, error) { if err := outputs.Register(pipeline.DefaultOutputModule, func() (contracts.OutputEncoder, error) {