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
|
The current Go library provides an instance-owned command registry, typed flag
|
||||||
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
|
and operand bindings, Cobra-backed routing, invocation outcomes, generated help,
|
||||||
and application or CommandKit version reporting. Standard configuration and
|
application or CommandKit version reporting, configuration scope discovery, and
|
||||||
PromptKit command families remain planned.
|
ConfigKit-backed validation. Effective display, source and explanation reports,
|
||||||
|
and PromptKit commands remain planned.
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
91
config.go
91
config.go
@@ -2,9 +2,11 @@ package commandkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"sort"
|
"sort"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
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")
|
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 {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
root, err := Group("config", "Inspect configuration.", command)
|
commands = append(commands, command)
|
||||||
|
root, err := Group("config", "Inspect configuration.", commands...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -144,6 +157,80 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
|
|||||||
return nil
|
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) {
|
func normalizeConfigSuite[C any](suite ConfigSuite[C]) (ConfigSuite[C], error) {
|
||||||
normalized := suite
|
normalized := suite
|
||||||
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
|
normalized.Selectors = append([]Selector[C](nil), suite.Selectors...)
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) {
|
|||||||
if outcome.Kind != OutcomeSuccess {
|
if outcome.Kind != OutcomeSuccess {
|
||||||
t.Fatalf("text inventory outcome = %#v", outcome)
|
t.Fatalf("text inventory outcome = %#v", outcome)
|
||||||
}
|
}
|
||||||
wantText := "app:\npromptkit:\nmetadata:\nzeta:\n"
|
wantText := "app: validate\npromptkit:\nmetadata:\nzeta:\n"
|
||||||
if textOutput.String() != wantText {
|
if textOutput.String() != wantText {
|
||||||
t.Fatalf("text inventory = %q, want %q", 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
|
declared scope capabilities and installed handlers, so an operation is listed
|
||||||
only when the corresponding command is available.
|
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
|
## Outcome status
|
||||||
|
|
||||||
CommandKit usage failures use status `2`; operational failures use status `1`.
|
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
|
decisions, implementation plans, identity and outcome primitives, typed command
|
||||||
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
declarations, an instance-owned registry, a Cobra-backed routing and parsing
|
||||||
adapter, public `App.Execute` coordination, and generated help/version commands.
|
adapter, public `App.Execute` coordination, and generated help/version commands.
|
||||||
Configuration suite registration and scope discovery are implemented; the
|
Configuration suite registration, scope discovery, ConfigKit integration, and
|
||||||
configuration operations remain pending. The
|
validation are implemented; other configuration and PromptKit operations remain
|
||||||
|
pending. The
|
||||||
[README](../README.md) provides project orientation; the
|
[README](../README.md) provides project orientation; the
|
||||||
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
[documentation policy](policy/documentation.md#canonical-ownership) assigns
|
||||||
canonical documentation owners.
|
canonical documentation owners.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Initial CommandKit implementation plan
|
# 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.
|
Date: 2026-10-07.
|
||||||
|
|
||||||
@@ -551,7 +551,7 @@ sequenced in Stages 8-11.
|
|||||||
|
|
||||||
## Stage 8: integrate ConfigKit and implement validation
|
## Stage 8: integrate ConfigKit and implement validation
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 7.
|
**Status:** complete. **Prerequisite:** Stage 7.
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func NewConfigKitScope[C, T any](name, summary string,
|
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
|
errors retain public identity and render once; all validates in order and stops
|
||||||
on failure. Report/diagnostic short writes retain their writer errors.
|
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
|
## Stage 9: implement native effective YAML display
|
||||||
|
|
||||||
**Status:** pending. **Prerequisite:** Stage 8.
|
**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 parseString(value string) (string, error) { return value, nil }
|
||||||
|
|
||||||
func preflightConfig[C any](suite ConfigSuite[C], input configInput[C], operation ConfigOperation) ([]string, error) {
|
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 {
|
if err := checkConfigFormat(input.Format); err != nil {
|
||||||
return nil, err
|
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)
|
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
|
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