diff --git a/docs/architecture.md b/docs/architecture.md index f0429f4..04d8687 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -182,14 +182,14 @@ Cycles are detected at build time and return a `ProviderCycleError`. ## Controllers -Controllers are built after providers and returned in `App.Controllers`: +Controllers are built after providers and returned in `App.Controllers`. Keys are namespaced as `module:controller`: ```go app, _ := kernel.Bootstrap(&AppModule{}) // Controllers are ready to use for name, controller := range app.Controllers { - fmt.Println(name) // e.g., "UsersController" + fmt.Println(name) // e.g., "users:UsersController" } ``` diff --git a/docs/reference/api.md b/docs/reference/api.md index 3b74dce..a344f6f 100644 --- a/docs/reference/api.md +++ b/docs/reference/api.md @@ -108,24 +108,28 @@ Entry point for bootstrapping your application. Returns an `App` with built cont ```go type App struct { Controllers map[string]any - Container Container } ``` | Field | Description | |-------|-------------| -| `Controllers` | Map of controller name → controller instance | -| `Container` | Access to the provider container | +| `Controllers` | Map of controller key (`module:controller`) → controller instance | -### Container +### App.Get ```go -type Container interface { - Get(token Token) (any, error) -} +func (a *App) Get(token Token) (any, error) +``` + +Resolves a token from the root module scope. + +### App.Resolver + +```go +func (a *App) Resolver() Resolver ``` -Provides access to built providers. Used for manual resolution or cleanup. +Returns a root-scoped resolver that enforces module visibility. ### Errors diff --git a/modkit/kernel/bootstrap.go b/modkit/kernel/bootstrap.go index 9db044e..16a425d 100644 --- a/modkit/kernel/bootstrap.go +++ b/modkit/kernel/bootstrap.go @@ -8,6 +8,10 @@ type App struct { Controllers map[string]any } +func controllerKey(moduleName, controllerName string) string { + return moduleName + ":" + controllerName +} + func Bootstrap(root module.Module) (*App, error) { graph, err := BuildGraph(root) if err != nil { @@ -25,17 +29,22 @@ func Bootstrap(root module.Module) (*App, error) { } controllers := make(map[string]any) + perModule := make(map[string]map[string]bool) for _, node := range graph.Modules { + if perModule[node.Name] == nil { + perModule[node.Name] = make(map[string]bool) + } resolver := container.resolverFor(node.Name) for _, controller := range node.Def.Controllers { - if _, exists := controllers[controller.Name]; exists { - return nil, &DuplicateControllerNameError{Name: controller.Name} + if perModule[node.Name][controller.Name] { + return nil, &DuplicateControllerNameError{Module: node.Name, Name: controller.Name} } + perModule[node.Name][controller.Name] = true instance, err := controller.Build(resolver) if err != nil { return nil, &ControllerBuildError{Module: node.Name, Controller: controller.Name, Err: err} } - controllers[controller.Name] = instance + controllers[controllerKey(node.Name, controller.Name)] = instance } } diff --git a/modkit/kernel/bootstrap_test.go b/modkit/kernel/bootstrap_test.go index 410046a..aa777c4 100644 --- a/modkit/kernel/bootstrap_test.go +++ b/modkit/kernel/bootstrap_test.go @@ -103,8 +103,8 @@ func TestBootstrapAllowsReExportedTokens(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - if app.Controllers["UsesShared"] != "value" { - t.Fatalf("unexpected controller value: %v", app.Controllers["UsesShared"]) + if app.Controllers["A:UsesShared"] != "value" { + t.Fatalf("unexpected controller value: %v", app.Controllers["A:UsesShared"]) } } @@ -171,6 +171,9 @@ func TestBootstrapRejectsDuplicateControllerNames(t *testing.T) { if dupErr.Name != "ControllerA" { t.Fatalf("unexpected controller name: %q", dupErr.Name) } + if dupErr.Module != "A" { + t.Fatalf("unexpected module name: %q", dupErr.Module) + } } func TestBootstrapRegistersControllers(t *testing.T) { @@ -189,7 +192,110 @@ func TestBootstrapRegistersControllers(t *testing.T) { t.Fatalf("Bootstrap failed: %v", err) } - if app.Controllers["ControllerA"] != "controller" { + if app.Controllers["A:ControllerA"] != "controller" { t.Fatalf("expected controller instance to be registered") } } + +func TestBootstrapAllowsSameControllerNameAcrossModules(t *testing.T) { + modB := mod("B", nil, nil, + []module.ControllerDef{{ + Name: "Shared", + Build: func(r module.Resolver) (any, error) { + return "controller-from-B", nil + }, + }}, + nil, + ) + + modA := mod("A", []module.Module{modB}, nil, + []module.ControllerDef{{ + Name: "Shared", + Build: func(r module.Resolver) (any, error) { + return "controller-from-A", nil + }, + }}, + nil, + ) + + app, err := kernel.Bootstrap(modA) + if err != nil { + t.Fatalf("expected no error, got: %v", err) + } + + if len(app.Controllers) != 2 { + t.Fatalf("expected 2 controllers, got %d", len(app.Controllers)) + } + + if app.Controllers["A:Shared"] != "controller-from-A" { + t.Errorf("controller A:Shared has wrong value: %v", app.Controllers["A:Shared"]) + } + if app.Controllers["B:Shared"] != "controller-from-B" { + t.Errorf("controller B:Shared has wrong value: %v", app.Controllers["B:Shared"]) + } +} + +func TestBootstrapRejectsDuplicateControllerInSameModule(t *testing.T) { + modA := mod("A", nil, nil, + []module.ControllerDef{ + { + Name: "Dup", + Build: func(r module.Resolver) (any, error) { + return "one", nil + }, + }, + { + Name: "Dup", + Build: func(r module.Resolver) (any, error) { + return "two", nil + }, + }, + }, + nil, + ) + + _, err := kernel.Bootstrap(modA) + if err == nil { + t.Fatalf("expected duplicate controller error") + } + + var dupErr *kernel.DuplicateControllerNameError + if !errors.As(err, &dupErr) { + t.Fatalf("unexpected error type: %T", err) + } + if dupErr.Name != "Dup" { + t.Fatalf("unexpected controller name: %q", dupErr.Name) + } + if dupErr.Module != "A" { + t.Fatalf("unexpected module name: %q", dupErr.Module) + } +} + +func TestControllerKeyFormat(t *testing.T) { + modA := mod("users", nil, nil, + []module.ControllerDef{{ + Name: "Controller", + Build: func(r module.Resolver) (any, error) { + return nil, nil + }, + }}, + nil, + ) + + app, err := kernel.Bootstrap(modA) + if err != nil { + t.Fatalf("Bootstrap failed: %v", err) + } + + if _, ok := app.Controllers["users:Controller"]; !ok { + t.Fatalf("expected key 'users:Controller', got keys: %v", controllerKeys(app.Controllers)) + } +} + +func controllerKeys(m map[string]any) []string { + keys := make([]string, 0, len(m)) + for key := range m { + keys = append(keys, key) + } + return keys +} diff --git a/modkit/kernel/errors.go b/modkit/kernel/errors.go index 87873c5..e6f241c 100644 --- a/modkit/kernel/errors.go +++ b/modkit/kernel/errors.go @@ -79,11 +79,12 @@ func (e *DuplicateProviderTokenError) Error() string { } type DuplicateControllerNameError struct { - Name string + Module string + Name string } func (e *DuplicateControllerNameError) Error() string { - return fmt.Sprintf("duplicate controller name: %s", e.Name) + return fmt.Sprintf("duplicate controller name in module %q: %s", e.Module, e.Name) } type TokenNotVisibleError struct {