Add ConfigKit validation and diagnostics
This commit is contained in:
@@ -5,8 +5,9 @@ and shared command infrastructure across related applications.
|
||||
|
||||
The current Go library provides an instance-owned command registry, typed flag
|
||||
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
|
||||
and application or CommandKit version reporting. Standard configuration and
|
||||
PromptKit command families remain planned.
|
||||
application or CommandKit version reporting, configuration scope discovery, and
|
||||
ConfigKit-backed validation. Effective display, source and explanation reports,
|
||||
and PromptKit commands remain planned.
|
||||
|
||||
## Documentation
|
||||
|
||||
|
||||
91
config.go
91
config.go
@@ -2,9 +2,11 @@ package commandkit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
@@ -129,11 +131,22 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
|
||||
return fmt.Errorf("command path %q is already registered", "config")
|
||||
}
|
||||
|
||||
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, nil)
|
||||
commands := make([]Command, 0, 2)
|
||||
installed := make([]ConfigOperation, 0, 1)
|
||||
if configOperationDeclared(normalized.Scopes, ConfigValidate) {
|
||||
validate, err := newConfigValidateCommand(normalized)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
commands = append(commands, validate)
|
||||
installed = append(installed, ConfigValidate)
|
||||
}
|
||||
command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
root, err := Group("config", "Inspect configuration.", command)
|
||||
commands = append(commands, command)
|
||||
root, err := Group("config", "Inspect configuration.", commands...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -144,6 +157,80 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewConfigKitScope adapts a ConfigKit loader into a configuration scope.
|
||||
func NewConfigKitScope[C, T any](name, summary string, load func(context.Context, Selection, C) (*configkit.Result[T], error)) (ConfigScope[C], error) {
|
||||
if load == nil {
|
||||
return ConfigScope[C]{}, fmt.Errorf("ConfigKit scope loader must not be nil")
|
||||
}
|
||||
return ConfigScope[C]{
|
||||
Name: name,
|
||||
Summary: summary,
|
||||
Operations: []ConfigOperation{ConfigValidate, ConfigShow, ConfigSources, ConfigExplain},
|
||||
Acquire: func(ctx context.Context, request ConfigRequest, contextValue C) (Acquired[ConfigView], error) {
|
||||
result, err := load(ctx, request.Selection, contextValue)
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
return Acquired[ConfigView]{Diagnostics: ConfigKitDiagnostics(name, loadError.Sources(), loadError.Diagnostics())}, err
|
||||
}
|
||||
return Acquired[ConfigView]{}, err
|
||||
}
|
||||
if result == nil {
|
||||
return Acquired[ConfigView]{}, fmt.Errorf("ConfigKit scope %q returned a nil result", name)
|
||||
}
|
||||
return Acquired[ConfigView]{
|
||||
Value: ConfigView{Effective: result, Sources: result, Explanation: result},
|
||||
Diagnostics: ConfigKitDiagnostics(name, result.Sources(), result.Diagnostics()),
|
||||
}, nil
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ConfigKitDiagnostics groups ConfigKit records with their scope and source legend.
|
||||
func ConfigKitDiagnostics(scope string, sources []configkit.SourceInfo, records []configkit.Diagnostic) []Diagnostic {
|
||||
if len(records) == 0 {
|
||||
return nil
|
||||
}
|
||||
severity := configkit.SeverityWarning
|
||||
for _, record := range records {
|
||||
if record.Severity == configkit.SeverityError {
|
||||
severity = configkit.SeverityError
|
||||
break
|
||||
}
|
||||
}
|
||||
return []Diagnostic{{
|
||||
Severity: severity,
|
||||
Write: func(writer io.Writer) error {
|
||||
if err := writeDiagnosticText(writer, "Configuration scope "+strconv.Quote(scope)+":\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(sources) > 0 {
|
||||
if err := writeDiagnosticText(writer, "Source IDs:\n"); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, source := range sources {
|
||||
line := " " + strconv.Quote(source.ID) + ": label=" + strconv.Quote(source.Label) + ", path=" + strconv.Quote(source.Path) + "\n"
|
||||
if err := writeDiagnosticText(writer, line); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
return configkit.WriteDiagnostics(writer, records)
|
||||
},
|
||||
}}
|
||||
}
|
||||
|
||||
func writeDiagnosticText(writer io.Writer, value string) error {
|
||||
written, err := io.WriteString(writer, value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if written != len(value) {
|
||||
return io.ErrShortWrite
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
|
||||
normalized := suite
|
||||
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
|
||||
|
||||
@@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
|
||||
if outcome.Kind != OutcomeSuccess {
|
||||
t.Fatalf("text inventory outcome = %#v", outcome)
|
||||
}
|
||||
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
|
||||
wantText := "app: validate\npromptkit:\nmetadata:\nzeta:\n"
|
||||
if textOutput.String() != wantText {
|
||||
t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText)
|
||||
}
|
||||
|
||||
242
configkit_test.go
Normal file
242
configkit_test.go
Normal file
@@ -0,0 +1,242 @@
|
||||
package commandkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
commandkit "gitea.maximumdirect.net/eric/CommandKit"
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestConfigKitScopeUsesDefaultsOverlayAndRecordedResolution(t *testing.T) {
|
||||
type configuration struct {
|
||||
Mode string `yaml:"mode"`
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{
|
||||
"config/main.yml": &fstest.MapFile{Data: []byte("mode: base\n")},
|
||||
"config/overlays/dev.yml": &fstest.MapFile{Data: []byte("mode: overlay\n")},
|
||||
})
|
||||
definition := configkit.Definition[configuration]{
|
||||
Defaults: func() configuration { return configuration{Defaulted: "from default"} },
|
||||
Resolve: func(input *configuration, resolution *configkit.Resolution) error {
|
||||
resolution.Record(configkit.Keys("mode"), configkit.ChangeNormalization, "normalized for use")
|
||||
input.Mode = strings.ToUpper(input.Mode)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
var received commandkit.Selection
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "Application settings.", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
|
||||
received = selection
|
||||
return configkit.LoadLayered(configkit.Input{Source: filesystem.File(selection.Config.Value)}, configkit.LayeredOptions[configuration]{
|
||||
Definition: definition,
|
||||
Overlay: selection.Overlay.Value,
|
||||
OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
|
||||
})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
selection := commandkit.Selection{
|
||||
Config: commandkit.Supplied[string]{Value: "config/main.yml", Set: true},
|
||||
Overlay: commandkit.Supplied[string]{Value: "dev", Set: true},
|
||||
}
|
||||
acquired, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{Operation: commandkit.ConfigValidate, Selection: selection}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(received, selection) {
|
||||
t.Fatalf("loader selection = %#v, want %#v", received, selection)
|
||||
}
|
||||
if acquired.Value.Effective == nil || acquired.Value.Sources == nil || acquired.Value.Explanation == nil {
|
||||
t.Fatalf("ConfigKit result facets = %#v", acquired.Value)
|
||||
}
|
||||
if got := acquired.Value.Sources.Sources(); len(got) < 2 {
|
||||
t.Fatalf("sources = %#v, want base and overlay", got)
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := acquired.Value.Effective.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(effective.String(), "mode: OVERLAY") || !strings.Contains(effective.String(), "defaulted: from default") {
|
||||
t.Fatalf("effective configuration = %q", effective.String())
|
||||
}
|
||||
if explanation, ok := acquired.Value.Explanation.Explain(configkit.Keys("mode")); !ok || len(explanation.History) == 0 {
|
||||
t.Fatalf("resolution explanation = %#v, present=%t", explanation, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitScopePreservesAbsentAndEmptySelections(t *testing.T) {
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
var seen []commandkit.Selection
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(_ context.Context, selection commandkit.Selection, _ struct{}) (*configkit.Result[configuration], error) {
|
||||
seen = append(seen, selection)
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{Defaults: func() configuration { return configuration{Value: "default"} }}})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
empty := commandkit.Selection{Config: commandkit.Supplied[string]{Set: true}, Overlay: commandkit.Supplied[string]{Set: true}}
|
||||
_, err = scope.Acquire(context.Background(), commandkit.ConfigRequest{Selection: empty}, struct{}{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := []commandkit.Selection{{}, empty}
|
||||
if !reflect.DeepEqual(seen, want) {
|
||||
t.Fatalf("loader selections = %#v, want %#v", seen, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitScopeRequiresLoaderAndNonNilResult(t *testing.T) {
|
||||
if _, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", nil); err == nil {
|
||||
t.Fatal("accepted a nil ConfigKit loader")
|
||||
}
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, struct{}]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[struct{}], error) {
|
||||
return nil, nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{}); err == nil || !strings.Contains(err.Error(), "nil result") {
|
||||
t.Fatalf("nil result error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigKitLoadErrorRetainsIdentityAndStructuredDiagnostics(t *testing.T) {
|
||||
type configuration struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
var loadError error
|
||||
scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
_, loadError = configkit.Load(configkit.Mapping("bad source", map[string]any{"unknown": true}), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{}})
|
||||
return nil, loadError
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
acquired, acquireError := scope.Acquire(context.Background(), commandkit.ConfigRequest{}, struct{}{})
|
||||
var publicLoadError *configkit.LoadError
|
||||
if acquireError != loadError || !errors.As(acquireError, &publicLoadError) {
|
||||
t.Fatalf("load error identity = %v (original %v), public type=%T", acquireError, loadError, acquireError)
|
||||
}
|
||||
if len(acquired.Diagnostics) != 1 || acquired.Diagnostics[0].Severity != configkit.SeverityError {
|
||||
t.Fatalf("structured diagnostics = %#v", acquired.Diagnostics)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := acquired.Diagnostics[0].Write(&rendered); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(rendered.String(), `Configuration scope "app":`) || !strings.Contains(rendered.String(), `"bad source"`) {
|
||||
t.Fatalf("grouped diagnostics = %q", rendered.String())
|
||||
}
|
||||
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{scope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Cause != loadError || len(outcome.Diagnostics) != 1 || strings.Count(diagnostic.String(), `Configuration scope "app":`) != 1 || output.Len() != 0 {
|
||||
t.Fatalf("presented load failure outcome=%#v output=%q diagnostics=%q", outcome, output.String(), diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationEmitsSuccessAsScopesCompleteAndStopsAtFailure(t *testing.T) {
|
||||
app, err := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var calls []string
|
||||
newContextCalls := 0
|
||||
failure := errors.New("promptkit could not load")
|
||||
suite := commandkit.ConfigSuite[string]{
|
||||
NewContext: func() string { newContextCalls++; return "runtime" },
|
||||
Scopes: []commandkit.ConfigScope[string]{
|
||||
{Name: "zeta", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "zeta")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}},
|
||||
{Name: "promptkit", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "promptkit")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, failure
|
||||
}},
|
||||
{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigValidate}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) {
|
||||
calls = append(calls, "app")
|
||||
return commandkit.Acquired[commandkit.ConfigView]{}, nil
|
||||
}},
|
||||
},
|
||||
}
|
||||
if err := commandkit.RegisterConfig(app, suite); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--scope", "all"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !errors.Is(outcome.Cause, failure) || !outcome.ReportIncomplete {
|
||||
t.Fatalf("validation outcome = %#v", outcome)
|
||||
}
|
||||
if !reflect.DeepEqual(calls, []string{"app", "promptkit"}) || newContextCalls != 1 {
|
||||
t.Fatalf("acquisitions = %v, NewContext calls=%d", calls, newContextCalls)
|
||||
}
|
||||
if output.String() != "app: valid\n" || !strings.Contains(diagnostic.String(), failure.Error()) {
|
||||
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfigValidationYAMLWarningsAndShortWrites(t *testing.T) {
|
||||
type configuration struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
warningScope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) {
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Validate: func(_ *configuration, validation *configkit.Validation) {
|
||||
validation.Warning(configkit.Keys("name"), "uses a demonstration name")
|
||||
},
|
||||
}})
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
app, _ := commandkit.New(commandkit.Identity{Name: "example"})
|
||||
if err := commandkit.RegisterConfig(app, commandkit.ConfigSuite[struct{}]{Scopes: []commandkit.ConfigScope[struct{}]{warningScope}}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output, diagnostic bytes.Buffer
|
||||
outcome := app.Execute(context.Background(), []string{"config", "validate", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: &diagnostic})
|
||||
if outcome.Kind != commandkit.OutcomeSuccess || outcome.Status != 0 || outcome.DiagnosticDelivery != commandkit.DeliveryComplete {
|
||||
t.Fatalf("warning validation outcome = %#v", outcome)
|
||||
}
|
||||
if output.String() != "scopes:\n - name: app\n valid: true\n" || !strings.Contains(diagnostic.String(), "uses a demonstration name") {
|
||||
t.Fatalf("output=%q diagnostics=%q", output.String(), diagnostic.String())
|
||||
}
|
||||
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: configKitPartialWriter{}, Err: io.Discard})
|
||||
if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.Cause, io.ErrShortWrite) {
|
||||
t.Fatalf("report short write outcome = %#v", outcome)
|
||||
}
|
||||
var delivered bytes.Buffer
|
||||
outcome = app.Execute(context.Background(), []string{"config", "validate"}, commandkit.Streams{Out: &delivered, Err: configKitPartialWriter{}})
|
||||
if outcome.DiagnosticDelivery != commandkit.DeliveryIncomplete || !errors.Is(outcome.DeliveryError, io.ErrShortWrite) {
|
||||
t.Fatalf("diagnostic short write outcome = %#v", outcome)
|
||||
}
|
||||
}
|
||||
|
||||
type configKitPartialWriter struct{}
|
||||
|
||||
func (configKitPartialWriter) Write(data []byte) (int, error) {
|
||||
if len(data) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
return len(data) - 1, nil
|
||||
}
|
||||
23
docs/cli.md
23
docs/cli.md
@@ -102,6 +102,29 @@ runtime context or acquire configuration. It reports the intersection between
|
||||
declared scope capabilities and installed handlers, so an operation is listed
|
||||
only when the corresponding command is available.
|
||||
|
||||
### Validate configuration
|
||||
|
||||
`config validate` loads and validates the selected scope. The default scope is
|
||||
`app`; `--scope all` validates every registered scope and is available when each
|
||||
scope declares validation. The command checks all selected capabilities before
|
||||
calling scope providers.
|
||||
Its default format is text; `--format yaml` emits a `scopes` sequence with
|
||||
`name` and `valid: true` for each scope that completed successfully.
|
||||
|
||||
```text
|
||||
example config validate
|
||||
example config validate --scope promptkit --format yaml
|
||||
example config validate --scope all
|
||||
```
|
||||
|
||||
Each successful scope is reported as `SCOPE: valid`. Aggregate validation stops
|
||||
at the first load or output failure; records from earlier successful scopes may
|
||||
already have been written. Provider warnings go to the diagnostic stream and do
|
||||
not alone make validation fail. `--config` and `--overlay` preserve whether an
|
||||
empty value was explicitly supplied, so a scope callback can map those inputs to
|
||||
its own ConfigKit load policy. Help and invalid syntax do not call scope
|
||||
providers.
|
||||
|
||||
## Outcome status
|
||||
|
||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
||||
|
||||
@@ -4,8 +4,9 @@ CommandKit currently contains adopted repository policies, accepted architectura
|
||||
decisions, implementation plans, identity and outcome primitives, typed command
|
||||
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
||||
adapter, public `App.Execute` coordination, and generated help/version commands.
|
||||
Configuration suite registration and scope discovery are implemented; the
|
||||
configuration operations remain pending. The
|
||||
Configuration suite registration, scope discovery, ConfigKit integration, and
|
||||
validation are implemented; other configuration and PromptKit operations remain
|
||||
pending. The
|
||||
[README](../README.md) provides project orientation; the
|
||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||
canonical documentation owners.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Initial CommandKit implementation plan
|
||||
|
||||
Status: implementation underway; Stages 1-7 complete and Stages 8-14 pending.
|
||||
Status: implementation underway; Stages 1-8 complete and Stages 9-14 pending.
|
||||
|
||||
Date: 2026-10-07.
|
||||
|
||||
@@ -551,7 +551,7 @@ sequenced in Stages 8-11.
|
||||
|
||||
## Stage 8: integrate ConfigKit and implement validation
|
||||
|
||||
**Status:** pending. **Prerequisite:** Stage 7.
|
||||
**Status:** complete. **Prerequisite:** Stage 7.
|
||||
|
||||
```go
|
||||
func NewConfigKitScope[C, T any](name, summary string,
|
||||
@@ -594,6 +594,16 @@ empty overlay callback input; warnings permit validation success; structured loa
|
||||
errors retain public identity and render once; all validates in order and stops
|
||||
on failure. Report/diagnostic short writes retain their writer errors.
|
||||
|
||||
**Completion evidence:** added the ConfigKit scope adapter and grouped diagnostic
|
||||
writer. The adapter exposes native effective/source/explanation facets, captures
|
||||
success and `LoadError` diagnostics, and preserves loader errors. `config validate`
|
||||
now installs for declared scopes, preflights all requested capabilities, acquires
|
||||
in stable order, and emits success records incrementally in text or YAML. Checks
|
||||
cover ConfigKit defaults, overlay, recorded resolution, absent/empty selections,
|
||||
warning success, load-error identity and single presentation, aggregate stop
|
||||
behavior, and report/diagnostic short writes. `go test ./...` passes; README,
|
||||
development orientation, and CLI reference describe the implemented boundary.
|
||||
|
||||
## Stage 9: implement native effective YAML display
|
||||
|
||||
**Status:** pending. **Prerequisite:** Stage 8.
|
||||
|
||||
18
selectors.go
18
selectors.go
@@ -64,6 +64,19 @@ func adaptSelector[C any](selector Selector[C]) Flag[configInput[C]] {
|
||||
func parseString(value string) (string, error) { return value, nil }
|
||||
|
||||
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
|
||||
scopes, err := selectConfigScopes(suite, input, operation)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if suite.CheckInput != nil {
|
||||
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
func selectConfigScopes[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
|
||||
if err := checkConfigFormat(input.Format); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -114,11 +127,6 @@ func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operatio
|
||||
return nil, fmt.Errorf("selector is not applicable to configuration scope %q", input.Scope)
|
||||
}
|
||||
}
|
||||
if suite.CheckInput != nil {
|
||||
if err := suite.CheckInput(input.Selection, input.SelectorContext); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return scopes, nil
|
||||
}
|
||||
|
||||
|
||||
104
validate.go
Normal file
104
validate.go
Normal file
@@ -0,0 +1,104 @@
|
||||
package commandkit
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"go.yaml.in/yaml/v4"
|
||||
)
|
||||
|
||||
func configOperationDeclared[C any](scopes []ConfigScope[C], operation ConfigOperation) bool {
|
||||
for _, scope := range scopes {
|
||||
if containsOperation(scope.Operations, operation) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func newConfigValidateCommand[C any](suite ConfigSuite[C]) (Command, error) {
|
||||
spec := CommandSpec[configInput[C]]{
|
||||
Name: string(ConfigValidate),
|
||||
Summary: "Validate selected configuration scopes.",
|
||||
Flags: configInputFlags(suite, "text"),
|
||||
CheckInput: func(input configInput[C]) error {
|
||||
_, err := preflightConfig(suite, input, ConfigValidate)
|
||||
return err
|
||||
},
|
||||
Run: func(invocation Invocation, input configInput[C]) Outcome {
|
||||
return runConfigValidation(invocation, suite, input)
|
||||
},
|
||||
}
|
||||
return NewCommand(spec)
|
||||
}
|
||||
|
||||
func runConfigValidation[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome {
|
||||
scopeNames, err := selectConfigScopes(suite, input, ConfigValidate)
|
||||
if err != nil {
|
||||
return Failure(err)
|
||||
}
|
||||
contexts := contextsForScopes(suite, input, scopeNames)
|
||||
diagnostics := make([]Diagnostic, 0)
|
||||
writtenRecord := false
|
||||
for _, name := range scopeNames {
|
||||
scope, ok := findConfigScope(suite.Scopes, name)
|
||||
if !ok || scope.Acquire == nil {
|
||||
return validationFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), diagnostics, writtenRecord)
|
||||
}
|
||||
acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigValidate, Selection: input.Selection}, contexts[name])
|
||||
diagnostics = append(diagnostics, acquired.Diagnostics...)
|
||||
if acquireErr != nil {
|
||||
return validationFailure(acquireErr, diagnostics, writtenRecord)
|
||||
}
|
||||
|
||||
record, err := validationSuccessRecord(name, input.Format)
|
||||
if err != nil {
|
||||
return validationFailure(err, diagnostics, writtenRecord)
|
||||
}
|
||||
if input.Format == "yaml" && !writtenRecord {
|
||||
if err := writeReport(invocation.Streams.Out, []byte("scopes:\n")); err != nil {
|
||||
return validationFailure(err, diagnostics, true)
|
||||
}
|
||||
}
|
||||
if err := writeReport(invocation.Streams.Out, record); err != nil {
|
||||
return validationFailure(err, diagnostics, true)
|
||||
}
|
||||
writtenRecord = true
|
||||
}
|
||||
outcome := Success()
|
||||
outcome.Diagnostics = diagnostics
|
||||
return outcome
|
||||
}
|
||||
|
||||
func validationFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome {
|
||||
outcome := Failure(cause)
|
||||
outcome.Diagnostics = diagnostics
|
||||
outcome.ReportIncomplete = reportStarted
|
||||
return outcome
|
||||
}
|
||||
|
||||
func validationSuccessRecord(scope, format string) ([]byte, error) {
|
||||
if format == "text" {
|
||||
return []byte(scope + ": valid\n"), nil
|
||||
}
|
||||
encoded, err := yaml.Marshal(struct {
|
||||
Name string `yaml:"name"`
|
||||
Valid bool `yaml:"valid"`
|
||||
}{Name: scope, Valid: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
lines := strings.Split(strings.TrimSuffix(string(encoded), "\n"), "\n")
|
||||
var record bytes.Buffer
|
||||
for index, line := range lines {
|
||||
if index == 0 {
|
||||
record.WriteString(" - ")
|
||||
} else {
|
||||
record.WriteString(" ")
|
||||
}
|
||||
record.WriteString(line)
|
||||
record.WriteByte('\n')
|
||||
}
|
||||
return record.Bytes(), nil
|
||||
}
|
||||
Reference in New Issue
Block a user