diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 6b36bcb..37876e8 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -3,7 +3,8 @@ Reviewed on 2026-10-11, against committed implementation `cc574915ee17e2d738f27874d2c8857aa1f51a47`. The implementation sprint starts after planning commit `0d23676144ab1ce5047924e98f1ab6a2a9d6aeb6`. -Completion status and evidence are recorded per issue below. P1 identifies a functional contract violation; P2 +Completion status and evidence are recorded per issue below. P1 identifies a +functional contract violation; P2 identifies architecture or documentation corrections; P3 identifies smaller maintenance corrections. These priorities do not add publication gates. @@ -82,7 +83,16 @@ Avoid assertions about a particular private representation. ## R2: Finish declaration-time compilation of configuration metadata -**Status:** Open. **Priority:** P2. +**Status:** Completed (2026-10-11). **Priority:** P2. + +**Completed work:** family capture orders scope references and prepares each +scope's original binding list, each operation's binding union, and scope-choice +text. Inspection preparation reads those lists without static sorting or +membership compilation. A behavioral regression covers operation-specific flags +and capture of capabilities/applicability/handles after source edits. Existing +ordering, metadata-only, unused/required-selector, isolated-input, and shared-state +coverage passes; all three module suites pass offline with Go 1.26.8. No provider +cache or performance guarantee was introduced. **Evidence:** [ckconfig/input.go](../../integrations/ckconfig/input.go), `newConfigOperationCommand`, `selectorIndexesForOperation`, and diff --git a/integrations/ckconfig/family.go b/integrations/ckconfig/family.go index 3062779..efcb45b 100644 --- a/integrations/ckconfig/family.go +++ b/integrations/ckconfig/family.go @@ -23,20 +23,21 @@ type scopeInventory struct { } type preparedScope[I, S any] struct { - scope Scope[I, S] - selectorIndexes []int + scope Scope[I, S] + bindings []commandkit.Flag[I] } type preparedSuite[I, S any] struct { - newInput func() I - newState func() S - checkInput func(string, Selection, I) error - scopes []preparedScope[I, S] - byName map[string]*preparedScope[I, S] - selectors []Selector[I] - operations []Operation - inventory scopeInventory - issues []error + newInput func() I + newState func() S + checkInput func(string, Selection, I) error + scopes []*preparedScope[I, S] + byName map[string]*preparedScope[I, S] + bindingsByOperation map[Operation][]commandkit.Flag[I] + scopeChoices string + operations []Operation + inventory scopeInventory + issues []error } // Command eagerly captures a configuration suite as a root-only config family. @@ -80,15 +81,16 @@ func AppCommand[T any](load func(context.Context, Selection) (*configkit.Result[ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { prepared := &preparedSuite[I, S]{ - newInput: suite.NewInput, - newState: suite.NewState, - checkInput: suite.CheckInput, - byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)), - selectors: make([]Selector[I], len(suite.Selectors)), + newInput: suite.NewInput, + newState: suite.NewState, + checkInput: suite.CheckInput, + byName: make(map[string]*preparedScope[I, S], len(suite.Scopes)), + bindingsByOperation: make(map[Operation][]commandkit.Flag[I]), } + selectors := make([]Selector[I], len(suite.Selectors)) for index, selector := range suite.Selectors { selector.Scopes = append([]string(nil), selector.Scopes...) - prepared.selectors[index] = selector + selectors[index] = selector } seenNames := make(map[string]struct{}, len(suite.Scopes)) @@ -113,16 +115,19 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { if len(scope.Operations) > 0 && scope.Acquire == nil { prepared.issues = append(prepared.issues, fmt.Errorf("configuration scope %q declares operations without an acquisition callback", scope.Name)) } - prepared.scopes = append(prepared.scopes, preparedScope[I, S]{scope: scope}) + prepared.scopes = append(prepared.scopes, &preparedScope[I, S]{scope: scope}) } - // Appending above may move the backing array. Rebuild the lookup only after - // the ordered scope slice is complete. - prepared.byName = make(map[string]*preparedScope[I, S], len(prepared.scopes)) - for index := range prepared.scopes { - prepared.byName[prepared.scopes[index].scope.Name] = &prepared.scopes[index] + sort.SliceStable(prepared.scopes, func(i, j int) bool { + return scopeOrder(prepared.scopes[i].scope.Name) < scopeOrder(prepared.scopes[j].scope.Name) + }) + names := make([]string, 0, len(prepared.scopes)) + for _, scope := range prepared.scopes { + prepared.byName[scope.scope.Name] = scope + names = append(names, scope.scope.Name) } + prepared.scopeChoices = joinNames(names) - for index, selector := range prepared.selectors { + for index, selector := range selectors { seen := make(map[string]struct{}, len(selector.Scopes)) uniqueScopes := make([]string, 0, len(selector.Scopes)) for _, name := range selector.Scopes { @@ -135,17 +140,17 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { seen[name] = struct{}{} uniqueScopes = append(uniqueScopes, name) } - prepared.selectors[index].Scopes = uniqueScopes + selectors[index].Scopes = uniqueScopes if len(selector.Scopes) == 0 { for scopeIndex := range prepared.scopes { if len(prepared.scopes[scopeIndex].scope.Operations) > 0 { - prepared.scopes[scopeIndex].selectorIndexes = append(prepared.scopes[scopeIndex].selectorIndexes, index) + prepared.scopes[scopeIndex].bindings = append(prepared.scopes[scopeIndex].bindings, selector.Binding) } } } else { for _, name := range uniqueScopes { if scope, exists := prepared.byName[name]; exists && len(scope.scope.Operations) > 0 { - scope.selectorIndexes = append(scope.selectorIndexes, index) + scope.bindings = append(scope.bindings, selector.Binding) } } } @@ -158,8 +163,16 @@ func captureSuite[I, S any](suite Suite[I, S]) *preparedSuite[I, S] { break } } + for _, selector := range selectors { + for _, scope := range prepared.scopes { + if containsOperation(scope.scope.Operations, operation) && selectorApplies(selector.Scopes, scope.scope.Name) { + prepared.bindingsByOperation[operation] = append(prepared.bindingsByOperation[operation], selector.Binding) + break + } + } + } } - for _, scope := range orderedScopeRecords(prepared.scopes) { + for _, scope := range prepared.scopes { prepared.inventory.Scopes = append(prepared.inventory.Scopes, scopeMetadata{ Name: scope.scope.Name, Operations: append([]Operation(nil), scope.scope.Operations...), @@ -187,14 +200,6 @@ func normalizeOperations(operations []Operation, issues *[]error, scope string) return ordered } -func orderedScopeRecords[I, S any](scopes []preparedScope[I, S]) []preparedScope[I, S] { - ordered := append([]preparedScope[I, S](nil), scopes...) - sort.SliceStable(ordered, func(i, j int) bool { - return scopeOrder(ordered[i].scope.Name) < scopeOrder(ordered[j].scope.Name) - }) - return ordered -} - func scopeOrder(name string) string { switch name { case "app": diff --git a/integrations/ckconfig/family_reports_test.go b/integrations/ckconfig/family_reports_test.go index a0a6daf..f311368 100644 --- a/integrations/ckconfig/family_reports_test.go +++ b/integrations/ckconfig/family_reports_test.go @@ -47,6 +47,60 @@ func TestScopeDiscoveryIsOrderedAndDoesNotUseSuiteFactories(t *testing.T) { } } +func TestOperationSelectorsAreCapturedForSupportedScopes(t *testing.T) { + type input struct{ Tenant, Campaign commandkit.Supplied[string] } + tenant := commandkit.ValueFlag(commandkit.FlagMeta{Name: "tenant", Required: true}, (*string)(nil), parseConfigString, + func(target *input, value commandkit.Supplied[string]) { target.Tenant = value }) + campaign := commandkit.ValueFlag(commandkit.FlagMeta{Name: "campaign", Required: true}, (*string)(nil), parseConfigString, + func(target *input, value commandkit.Supplied[string]) { target.Campaign = value }) + calls := 0 + suite := Suite[input, struct{}]{ + Selectors: []Selector[input]{{Binding: tenant, Scopes: []string{"app"}}, {Binding: campaign, Scopes: []string{"promptkit"}}}, + Scopes: []Scope[input, struct{}]{ + {Name: "app", Operations: []Operation{Validate}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) { + calls++ + if !value.Tenant.Set || value.Tenant.Value != "team" || value.Campaign.Set { + t.Errorf("app input = %#v", value) + } + return commandkit.Acquired[View]{}, nil + }}, + {Name: "promptkit", Operations: []Operation{Sources}, Acquire: func(_ context.Context, _ Request, value input, _ struct{}) (commandkit.Acquired[View], error) { + calls++ + if !value.Campaign.Set || value.Campaign.Value != "spring" || value.Tenant.Set { + t.Errorf("promptkit input = %#v", value) + } + return commandkit.Acquired[View]{Value: View{Sources: &sourceInventory{}}}, nil + }}, + }, + } + app := newConfigTestApp(t, Command(suite)) + // Captured capabilities, applicability, and handles survive source edits. + suite.Scopes[0].Operations[0] = Sources + suite.Selectors[0].Scopes[0] = "promptkit" + suite.Selectors[1].Binding = commandkit.Flag[input]{} + for _, args := range [][]string{ + {"config", "validate", "--tenant", "team"}, + {"config", "sources", "--scope", "promptkit", "--campaign", "spring"}, + } { + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess { + t.Fatalf("%v: %#v", args, outcome) + } + } + for _, args := range [][]string{ + {"config", "validate", "--campaign", "spring"}, + {"config", "sources", "--scope", "promptkit", "--tenant", "team"}, + } { + outcome := app.Execute(context.Background(), args, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage { + t.Fatalf("selector for another operation accepted: %v: %#v", args, outcome) + } + } + if calls != 2 { + t.Fatalf("acquisition calls = %d, want 2", calls) + } +} + func TestValidationPreparesIndependentScopeInputsAndOneSharedState(t *testing.T) { type inspectInput struct { Campaign commandkit.Supplied[string] diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index c540a9e..d6a63ef 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -56,12 +56,7 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) } - selectorIndexes := selectorIndexesForOperation(prepared, operation) - selectorBindings := make([]commandkit.Flag[I], 0, len(selectorIndexes)) - for _, index := range selectorIndexes { - selectorBindings = append(selectorBindings, prepared.selectors[index].Binding) - } - flags = append(flags, commandkit.CaptureFlags(selectorBindings, func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { + flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { if input == nil || *input == nil { return nil } @@ -85,19 +80,6 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio }) } -func selectorIndexesForOperation[I, S any](prepared *preparedSuite[I, S], operation Operation) []int { - used := make([]int, 0, len(prepared.selectors)) - for index := range prepared.selectors { - for _, scope := range prepared.scopes { - if containsOperation(scope.scope.Operations, operation) && selectorApplies(prepared.selectors[index].Scopes, scope.scope.Name) { - used = append(used, index) - break - } - } - } - return used -} - func operationDeclaration(operation Operation) (string, string) { switch operation { case Validate: @@ -133,19 +115,13 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O return fmt.Errorf("scope all is supported only for validate and show") } - selected := make([]*preparedScope[I, S], 0, len(prepared.scopes)) + var selected []*preparedScope[I, S] if scopeName == "all" { - for _, scope := range orderedScopeRecords(prepared.scopes) { - selected = append(selected, prepared.byName[scope.scope.Name]) - } + selected = prepared.scopes } else if scope, exists := prepared.byName[scopeName]; exists { - selected = append(selected, scope) + selected = []*preparedScope[I, S]{scope} } else { - choices := make([]string, 0, len(prepared.scopes)) - for _, scope := range orderedScopeRecords(prepared.scopes) { - choices = append(choices, scope.scope.Name) - } - return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, joinNames(choices)) + return fmt.Errorf("unknown configuration scope %q (available: %s)", scopeName, prepared.scopeChoices) } for _, scope := range selected { @@ -175,23 +151,14 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O path: path, scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } - operationSelectors := selectorIndexesForOperation(prepared, operation) - capturedSelectors := make(map[int]struct{}, len(operationSelectors)) - for _, index := range operationSelectors { - capturedSelectors[index] = struct{}{} - } for _, scope := range selected { var value I if prepared.newInput != nil { value = prepared.newInput() } - bindings := make([]commandkit.Flag[I], 0, len(scope.selectorIndexes)) - for _, index := range scope.selectorIndexes { - if _, ok := capturedSelectors[index]; ok { - bindings = append(bindings, prepared.selectors[index].Binding) - } - } - if err := input.values.ApplyTo(&value, bindings...); err != nil { + // Availability was checked above, so every binding for this scope + // belongs to the operation's captured union. + if err := input.values.ApplyTo(&value, scope.bindings...); err != nil { return fmt.Errorf("configuration scope %q: %w", scope.scope.Name, err) } if prepared.checkInput != nil {