Install command batches through one path

This commit is contained in:
2026-10-11 01:22:47 +00:00
parent 74f6bdeade
commit 24e8cd8629
8 changed files with 87 additions and 46 deletions

30
app.go
View File

@@ -8,15 +8,13 @@ import (
// App owns one command registry and its application identity. // App owns one command registry and its application identity.
type App struct { type App struct {
identity Identity identity Identity
commands map[string]*commandNode commands map[string]*commandNode
commandTree *internalcobra.Command commandTree *internalcobra.Command
configRegistered bool
} }
type commandNode struct { type commandNode struct {
command Command command Command
reserved bool
} }
// New creates an application with the supplied identity and initial commands. // New creates an application with the supplied identity and initial commands.
@@ -27,23 +25,9 @@ func New(identity Identity, commands ...Command) (*App, error) {
return nil, fmt.Errorf("application identity name must not be empty") return nil, fmt.Errorf("application identity name must not be empty")
} }
app := &App{ app := &App{identity: identity, commands: make(map[string]*commandNode)}
identity: identity, all := append(app.builtinCommands(), commands...)
commands: map[string]*commandNode{ if err := app.installBatch(all...); err != nil {
"help": {
command: Command{record: &commandRecord{name: "help", summary: "Show help for commands."}},
reserved: true,
},
"version": {
command: Command{record: &commandRecord{name: "version", summary: "Show application version."}},
reserved: true,
},
},
}
if err := app.installBuiltins(); err != nil {
return nil, err
}
if err := app.Register(commands...); err != nil {
return nil, err return nil, err
} }
return app, nil return app, nil

View File

@@ -29,7 +29,7 @@ type versionDocument struct {
Revision string `yaml:"revision"` Revision string `yaml:"revision"`
} }
func (a *App) installBuiltins() error { func (a *App) builtinCommands() []Command {
help := captureCommand(CommandSpec[helpInput]{ help := captureCommand(CommandSpec[helpInput]{
Name: "help", Name: "help",
Summary: "Show help for commands.", Summary: "Show help for commands.",
@@ -61,9 +61,7 @@ func (a *App) installBuiltins() error {
return renderVersionOutcome(identity, input.Format, invocation.Streams) return renderVersionOutcome(identity, input.Format, invocation.Streams)
}, },
}) })
a.commands["help"] = &commandNode{command: help, reserved: true} return []Command{help, version}
a.commands["version"] = &commandNode{command: version, reserved: true}
return nil
} }
func parseVersionScope(value string) (string, error) { func parseVersionScope(value string) (string, error) {

View File

@@ -263,12 +263,16 @@ func captureComposition(record *commandRecord) {
} }
func validateCommandDeclaration(command Command) error { func validateCommandDeclaration(command Command) error {
return validateCommandPlacement(command)
}
func validateCommandPlacement(command Command) error {
var issues []error var issues []error
collectDeclarationIssues(command, nil, &issues) collectDeclarationIssues(command, nil, true, &issues)
return errors.Join(issues...) return errors.Join(issues...)
} }
func collectDeclarationIssues(command Command, parents []string, issues *[]error) { func collectDeclarationIssues(command Command, parents []string, root bool, issues *[]error) {
if command.record == nil { if command.record == nil {
*issues = append(*issues, errors.New("invalid zero command declaration")) *issues = append(*issues, errors.New("invalid zero command declaration"))
return return
@@ -283,6 +287,9 @@ func collectDeclarationIssues(command Command, parents []string, issues *[]error
*issues = append(*issues, issue) *issues = append(*issues, issue)
} }
} }
if record.rootOnly && !root {
*issues = append(*issues, fmt.Errorf("root-only command %q is nested beneath another command", label))
}
if record.invalid || record.absent { if record.invalid || record.absent {
return return
} }
@@ -290,7 +297,7 @@ func collectDeclarationIssues(command Command, parents []string, issues *[]error
if child.record == nil { if child.record == nil {
continue continue
} }
collectDeclarationIssues(child, path, issues) collectDeclarationIssues(child, path, false, issues)
} }
} }

View File

@@ -1,6 +1,6 @@
# Implement the optional integration modules and captured registration API # Implement the optional integration modules and captured registration API
Status: implementation in progress. Stages 1 through 5 are complete; subsequent work follows the ordered roadmap. Status: implementation in progress. Stages 1 through 6 are complete; subsequent work follows the ordered roadmap.
Execute the stages in order. Each stage is sized as a separate Luna coding-agent Execute the stages in order. Each stage is sized as a separate Luna coding-agent
assignment. This document authorizes no publication or changes to sibling assignment. This document authorizes no publication or changes to sibling
repositories. repositories.
@@ -321,10 +321,14 @@ for routing snapshot/session consolidation.
`configRegistered`, integration collision prechecks, and root namespace `configRegistered`, integration collision prechecks, and root namespace
allowlists. Integration factories install through the public declaration path. allowlists. Integration factories install through the public declaration path.
**Completion evidence:** conflicting roots in either order, invalid final batch **Completion evidence — complete:** `New` now submits real help/version
member, nested root-only commands, no-op errors, explicit absence, empty real commands and caller declarations to the same private batch installer used by
groups, and declaration reuse across two apps. Inspect that registry writes have `Register`. Retained errors are checked before absence; nested root-only placement,
one boundary, without making its private name a public contract. root/batch collisions, and invalid final members fail before registry mutation.
No-op-only registration leaves the cached tree intact; real empty groups and
reused declarations install correctly. Direct built-in writes, placeholders,
`configRegistered`, and the registry reservation flag were removed. Registry
mutation now has one boundary. Root tests pass.
## Stage 7: Connect neutral routing, Cobra sessions, and execution ## Stage 7: Connect neutral routing, Cobra sessions, and execution

View File

@@ -147,7 +147,7 @@ func (a *App) commandAt(path []string) (Command, bool) {
return Command{}, true return Command{}, true
} }
command, ok := a.commands[path[0]] command, ok := a.commands[path[0]]
if !ok || (command.reserved && command.command.record.payload == nil) { if !ok {
return Command{}, false return Command{}, false
} }
current := command.command current := command.command

View File

@@ -26,13 +26,8 @@ func (a *App) bridgeTree() *internalcobra.Command {
func (a *App) buildBridgeTree() *internalcobra.Command { func (a *App) buildBridgeTree() *internalcobra.Command {
root := &internalcobra.Command{Name: a.identity.Name} root := &internalcobra.Command{Name: a.identity.Name}
names := make([]string, 0, len(a.commands)) names := make([]string, 0, len(a.commands))
for name, node := range a.commands { for name := range a.commands {
if node.command.record != nil && node.command.record.absent { names = append(names, name)
continue
}
if !node.reserved || node.command.record.payload != nil {
names = append(names, name)
}
} }
sort.Strings(names) sort.Strings(names)
for _, name := range names { for _, name := range names {

View File

@@ -5,13 +5,17 @@ import "fmt"
// Register adds a batch of root commands to the application. The batch is // Register adds a batch of root commands to the application. The batch is
// validated before the registry is changed. // validated before the registry is changed.
func (a *App) Register(commands ...Command) error { func (a *App) Register(commands ...Command) error {
return a.installBatch(commands...)
}
func (a *App) installBatch(commands ...Command) error {
if a == nil { if a == nil {
return fmt.Errorf("cannot register commands on a nil app") return fmt.Errorf("cannot register commands on a nil app")
} }
pending := make(map[string]*commandNode, len(commands)) pending := make(map[string]*commandNode, len(commands))
for _, command := range commands { for _, command := range commands {
if err := validateCommandDeclaration(command); err != nil { if err := validateCommandPlacement(command); err != nil {
return err return err
} }
if command.record.absent { if command.record.absent {

View File

@@ -56,8 +56,8 @@ func TestAppsHaveIndependentRegistries(t *testing.T) {
if _, exists := second.commands["custom"]; exists { if _, exists := second.commands["custom"]; exists {
t.Fatal("registration leaked to another app") t.Fatal("registration leaked to another app")
} }
if !first.commands["help"].reserved || !first.commands["version"].reserved { if first.commands["help"] == nil || first.commands["help"].command.record.payload == nil || first.commands["version"] == nil || first.commands["version"].command.record.payload == nil {
t.Fatal("New did not reserve generated root commands") t.Fatal("New did not install real help and version declarations")
} }
} }
@@ -138,6 +138,55 @@ func TestRootGroupRetainsPlacementMetadata(t *testing.T) {
} }
} }
func TestBatchInstallationRejectsNestedRootOnlyAndKeepsExistingState(t *testing.T) {
root := RootGroup("config", "Configuration.")
app, err := New(Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
if err := app.Register(Group("outer", "", root)); err == nil {
t.Fatal("installation accepted nested root-only declaration")
}
if _, exists := app.commands["outer"]; exists {
t.Fatal("failed batch partially installed its parent")
}
if err := app.Register(root); err != nil {
t.Fatalf("root-level declaration rejected: %v", err)
}
}
func TestNoOpOnlyRegistrationPreservesRoutingSnapshot(t *testing.T) {
app, err := New(Identity{Name: "Example"})
if err != nil {
t.Fatal(err)
}
before := app.bridgeTree()
if err := app.Register(NoCommand()); err != nil {
t.Fatal(err)
}
if after := app.bridgeTree(); after != before {
t.Fatal("no-op-only registration invalidated derived metadata")
}
}
func TestCapturedDeclarationCanBeInstalledIntoMultipleApps(t *testing.T) {
declaration := Group("tools", "", NewCommand(CommandSpec[struct{}]{Name: "run"}))
first, err := New(Identity{Name: "First"}, declaration)
if err != nil {
t.Fatal(err)
}
second, err := New(Identity{Name: "Second"}, declaration)
if err != nil {
t.Fatal(err)
}
if _, ok := first.commandAt([]string{"tools", "run"}); !ok {
t.Fatal("first app lost reused declaration")
}
if _, ok := second.commandAt([]string{"tools", "run"}); !ok {
t.Fatal("second app lost reused declaration")
}
}
func TestWithErrorCopiesDeclarationAndRetainsWrappedCauses(t *testing.T) { func TestWithErrorCopiesDeclarationAndRetainsWrappedCauses(t *testing.T) {
first := errors.New("first construction issue") first := errors.New("first construction issue")
second := errors.New("second construction issue") second := errors.New("second construction issue")