From 25b412ea937ac10811b6ba40383d2952b0c49307 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 7 Oct 2026 14:05:26 +0000 Subject: [PATCH] Add configuration source reports --- README.md | 4 +- config.go | 12 ++- config_test.go | 2 +- configkit_test.go | 149 +++++++++++++++++++++++++++++++++ docs/cli.md | 26 ++++++ docs/development.md | 4 +- docs/roadmap/implementation.md | 14 +++- sources.go | 128 ++++++++++++++++++++++++++++ 8 files changed, 330 insertions(+), 9 deletions(-) create mode 100644 sources.go diff --git a/README.md b/README.md index f3ba881..8f0dbb1 100644 --- a/README.md +++ b/README.md @@ -6,8 +6,8 @@ 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, application or CommandKit version reporting, configuration scope discovery, and -ConfigKit-backed validation and native effective YAML display. Source and -explanation reports and PromptKit commands remain planned. +ConfigKit-backed validation, native effective YAML display, and source inventory +reports. Explanation reports and PromptKit commands remain planned. ## Documentation diff --git a/config.go b/config.go index 3b2b8c1..b31e337 100644 --- a/config.go +++ b/config.go @@ -131,8 +131,8 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { return fmt.Errorf("command path %q is already registered", "config") } - commands := make([]Command, 0, 3) - installed := make([]ConfigOperation, 0, 2) + commands := make([]Command, 0, 4) + installed := make([]ConfigOperation, 0, 3) if configOperationDeclared(normalized.Scopes, ConfigValidate) { validate, err := newConfigValidateCommand(normalized) if err != nil { @@ -149,6 +149,14 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error { commands = append(commands, show) installed = append(installed, ConfigShow) } + if configOperationDeclared(normalized.Scopes, ConfigSources) { + sources, err := newConfigSourcesCommand(normalized) + if err != nil { + return err + } + commands = append(commands, sources) + installed = append(installed, ConfigSources) + } command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed) if err != nil { return err diff --git a/config_test.go b/config_test.go index 61d1e44..57d221f 100644 --- a/config_test.go +++ b/config_test.go @@ -47,7 +47,7 @@ func TestConfigScopeDiscoveryOrdersAndDeduplicatesCapabilities(t *testing.T) { if outcome.Kind != OutcomeSuccess { t.Fatalf("text inventory outcome = %#v", outcome) } - wantText := "app: validate, show\npromptkit:\nmetadata:\nzeta: show\n" + wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show\n" if textOutput.String() != wantText { t.Fatalf("text inventory = %q, want %q", textOutput.String(), wantText) } diff --git a/configkit_test.go b/configkit_test.go index a023ba0..5f620ed 100644 --- a/configkit_test.go +++ b/configkit_test.go @@ -22,6 +22,155 @@ func (document effectiveYAML) WriteEffective(writer io.Writer) error { return err } +type capturedSourceReader struct { + entries []configkit.SourceInfo + calls int +} + +func (reader *capturedSourceReader) Sources() []configkit.SourceInfo { + reader.calls++ + return reader.entries +} + +func TestConfigSourcesReportsCapturedConfigKitSourceInventory(t *testing.T) { + type configuration struct { + Value string `yaml:"value"` + Shared string `yaml:"shared"` + Synthetic string `yaml:"synthetic"` + } + filesystem := configkit.NewFilesystem("fixture", fstest.MapFS{ + "root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nvalue: base\n")}, + "root/shared.yml": &fstest.MapFile{Data: []byte("shared: imported\n")}, + "root/overlays/dev.yml": &fstest.MapFile{Data: []byte("value: overlay\n")}, + }) + filesystemContext := &configkit.FilesystemContext{Root: "root", FS: &filesystem} + overlayFilesystemContext := &configkit.FilesystemContext{Root: "root", BaseDir: "root/config", FS: &filesystem} + loaderCalls := 0 + reader := &capturedSourceReader{} + scope, err := commandkit.NewConfigKitScope[struct{}, configuration]("app", "", func(context.Context, commandkit.Selection, struct{}) (*configkit.Result[configuration], error) { + loaderCalls++ + result, err := configkit.LoadLayered(configkit.Input{ + Source: filesystem.File("root/config/main.yml"), + Imports: true, + Filesystem: filesystemContext, + }, configkit.LayeredOptions[configuration]{ + Definition: configkit.Definition[configuration]{}, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("synthetic source", map[string]any{"synthetic": "underlay"})}}}}, + Overlay: "dev", + OverlayFilesystem: overlayFilesystemContext, + }) + if err != nil { + return nil, err + } + reader.entries = result.Sources() + return result, nil + }) + if err != nil { + t.Fatal(err) + } + // Replace the result's reader after the owning loader has captured its source inventory. + originalAcquire := scope.Acquire + scope.Acquire = func(ctx context.Context, request commandkit.ConfigRequest, input struct{}) (commandkit.Acquired[commandkit.ConfigView], error) { + acquired, err := originalAcquire(ctx, request, input) + if err == nil { + acquired.Value.Sources = reader + } + return acquired, err + } + // Register the wrapped scope so the command reads the captured public facet once. + 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 bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || loaderCalls != 1 || reader.calls != 1 { + var details bytes.Buffer + for _, diagnostic := range outcome.Diagnostics { + _ = diagnostic.Write(&details) + } + t.Fatalf("sources outcome=%#v cause=%v details=%q loader calls=%d reader calls=%d", outcome, outcome.Cause, details.String(), loaderCalls, reader.calls) + } + var report struct { + Scope string `yaml:"scope"` + Sources []struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Path string `yaml:"path"` + Layer string `yaml:"layer"` + Kind string `yaml:"kind"` + UnderlayIndex int `yaml:"underlay_index"` + ImportedFrom *struct { + SourceID string `yaml:"source_id"` + Line int `yaml:"line"` + Column int `yaml:"column"` + } `yaml:"imported_from"` + } `yaml:"sources"` + } + if err := yaml.Unmarshal(output.Bytes(), &report); err != nil { + t.Fatal(err) + } + if report.Scope != "app" || len(report.Sources) != 4 { + t.Fatalf("source report = %#v", report) + } + if report.Sources[0].Path != "root/config/main.yml" || report.Sources[0].ID != "source-1" { + t.Fatalf("main source = %#v", report.Sources[0]) + } + if report.Sources[0].ImportedFrom != nil { + t.Fatalf("root source imported_from = %#v, want null", report.Sources[0].ImportedFrom) + } + imported := report.Sources[1] + if imported.Path != "root/shared.yml" || imported.ImportedFrom == nil || imported.ImportedFrom.SourceID != report.Sources[0].ID || imported.ImportedFrom.Line == 0 { + t.Fatalf("import source correlation = %#v", imported) + } + synthetic := report.Sources[2] + if synthetic.Kind != string(configkit.SourceKindMapping) || synthetic.Path != "" || synthetic.Layer != string(configkit.LayerUnderlay) || synthetic.UnderlayIndex != 1 { + t.Fatalf("synthetic underlay source = %#v", synthetic) + } + if report.Sources[3].Layer != string(configkit.LayerOverlay) || report.Sources[3].Path != "root/overlays/dev.yml" { + t.Fatalf("overlay source = %#v", report.Sources[3]) + } +} + +func TestConfigSourcesHandlesEmptyInventoryAllPreflightAndWriterFailure(t *testing.T) { + reader := &capturedSourceReader{} + newContextCalls, acquireCalls := 0, 0 + app, err := commandkit.New(commandkit.Identity{Name: "example"}) + if err != nil { + t.Fatal(err) + } + suite := commandkit.ConfigSuite[string]{ + NewContext: func() string { newContextCalls++; return "context" }, + Scopes: []commandkit.ConfigScope[string]{{Name: "app", Operations: []commandkit.ConfigOperation{commandkit.ConfigSources}, Acquire: func(context.Context, commandkit.ConfigRequest, string) (commandkit.Acquired[commandkit.ConfigView], error) { + acquireCalls++ + return commandkit.Acquired[commandkit.ConfigView]{Value: commandkit.ConfigView{Sources: reader}}, nil + }}}, + } + if err := commandkit.RegisterConfig(app, suite); err != nil { + t.Fatal(err) + } + var output bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "sources", "--scope", "all"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeUsage || newContextCalls != 0 || acquireCalls != 0 || output.Len() != 0 { + t.Fatalf("all preflight outcome=%#v contexts=%d acquires=%d output=%q", outcome, newContextCalls, acquireCalls, output.String()) + } + outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &output, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || output.String() != "scope: app\nsources: []\n" || reader.calls != 1 { + t.Fatalf("empty source report outcome=%#v output=%q reader calls=%d", outcome, output.String(), reader.calls) + } + reader.entries = []configkit.SourceInfo{{ID: "source-1", Label: "label with \"quotes\"", Path: "path with spaces.yml", Layer: configkit.LayerBase, Kind: configkit.SourceKindFile}} + var textOutput bytes.Buffer + outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: &textOutput, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || !strings.Contains(textOutput.String(), `label: "label with \"quotes\""`) || !strings.Contains(textOutput.String(), `path: "path with spaces.yml"`) { + t.Fatalf("quoted text source output outcome=%#v output=%q", outcome, textOutput.String()) + } + partial := &configKitShowPartialWriter{failure: io.ErrClosedPipe} + outcome = app.Execute(context.Background(), []string{"config", "sources"}, commandkit.Streams{Out: partial, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeOperational || !outcome.ReportIncomplete || !errors.Is(outcome.DeliveryError, io.ErrClosedPipe) { + t.Fatalf("source writer failure = %#v", outcome) + } +} + func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) { const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n" app, err := commandkit.New(commandkit.Identity{Name: "example"}) diff --git a/docs/cli.md b/docs/cli.md index 00efdb1..2d0f3be 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -147,6 +147,32 @@ one comment line. A provider failure leaves the output stream empty. A rendering or output failure may leave an aggregate prefix, and the outcome reports incomplete report delivery. +### List configuration sources + +`config sources` reports the source inventory captured during the selected +scope's acquisition. It reads the source facet once and does not reload +configuration. The default scope is `app`; `--scope all` is unsupported. Text is +the default format, and `--format yaml` returns a structured report: + +```yaml +scope: app +sources: + - id: source-1 + label: "application config" + path: /etc/example/config.yml + layer: base + kind: file + underlay_index: 0 + imported_from: null +``` + +Each source includes `id`, `label`, `path`, `layer`, `kind`, `underlay_index`, +and `imported_from`. The import location is null when absent; otherwise it has +`source_id`, `line`, and `column`. Owning values and source order are preserved, +including empty fields, zero coordinates, and synthetic sources without paths. +An empty inventory is `sources: []`. Text output begins with the scope heading +and then prints the same source fields, quoting labels and paths. + ## Outcome status CommandKit usage failures use status `2`; operational failures use status `1`. diff --git a/docs/development.md b/docs/development.md index 9e286cf..0b6f3c2 100644 --- a/docs/development.md +++ b/docs/development.md @@ -5,8 +5,8 @@ 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, scope discovery, ConfigKit integration, -validation, and native effective YAML display are implemented; other -configuration and PromptKit operations remain pending. The +validation, native effective YAML display, and source inventory reports 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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 032890b..ade15f2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Initial CommandKit implementation plan -Status: implementation underway; Stages 1-9 complete and Stages 10-14 pending. +Status: implementation underway; Stages 1-10 complete and Stages 11-14 pending. Date: 2026-10-07. @@ -645,7 +645,7 @@ passes; README, development orientation, and CLI reference describe the feature. ## Stage 10: implement source inventory reports -**Status:** pending. **Prerequisite:** Stage 9. +**Status:** complete. **Prerequisite:** Stage 9. 1. Implement single-scope `config sources`, default text with yaml alternative. Read the captured SourceReader once. Never reload sources for this report. @@ -664,6 +664,16 @@ one captured acquisition, YAML field types and import correlation, empty invento unsupported all usage preflight, writer failure. Update the source-report CLI reference with the implemented shape. +**Completion evidence:** installed `config sources` for scopes declaring the +capability. The command reads the acquired source facet once and renders explicit +snake-case YAML DTOs or a quoted text inventory, preserving source order, empty +and zero values, synthetic paths, and import locations. Checks use a real +ConfigKit filesystem root, import, overlay, and mapping underlay; they verify the +YAML field types and import correlation, captured acquisition, empty inventory, +text quoting, unsupported aggregate preflight, and partial writer failure. +`go test ./...` passes; README, development orientation, and CLI reference +describe the report. + ## Stage 11: implement path explanation reports **Status:** pending. **Prerequisite:** Stage 10. diff --git a/sources.go b/sources.go new file mode 100644 index 0000000..4f18f33 --- /dev/null +++ b/sources.go @@ -0,0 +1,128 @@ +package commandkit + +import ( + "bytes" + "fmt" + "strconv" +) + +func newConfigSourcesCommand[C any](suite ConfigSuite[C]) (Command, error) { + spec := CommandSpec[configInput[C]]{ + Name: string(ConfigSources), + Summary: "List configuration sources used by a scope.", + Flags: configInputFlags(suite, "text"), + CheckInput: func(input configInput[C]) error { + _, err := preflightConfig(suite, input, ConfigSources) + return err + }, + Run: func(invocation Invocation, input configInput[C]) Outcome { + return runConfigSources(invocation, suite, input) + }, + } + return NewCommand(spec) +} + +type sourceReport struct { + Scope string `yaml:"scope"` + Sources []sourceDTO `yaml:"sources"` +} + +type sourceDTO struct { + ID string `yaml:"id"` + Label string `yaml:"label"` + Path string `yaml:"path"` + Layer string `yaml:"layer"` + Kind string `yaml:"kind"` + UnderlayIndex int `yaml:"underlay_index"` + ImportedFrom *locationDTO `yaml:"imported_from"` +} + +type locationDTO struct { + SourceID string `yaml:"source_id"` + Line int `yaml:"line"` + Column int `yaml:"column"` +} + +func runConfigSources[C any](invocation Invocation, suite ConfigSuite[C], input configInput[C]) Outcome { + scopeNames, err := selectConfigScopes(suite, input, ConfigSources) + if err != nil { + return Failure(err) + } + contexts := contextsForScopes(suite, input, scopeNames) + name := scopeNames[0] + scope, ok := findConfigScope(suite.Scopes, name) + if !ok || scope.Acquire == nil { + return sourceReportFailure(fmt.Errorf("configuration scope %q has no acquisition callback", name), nil, false) + } + acquired, acquireErr := scope.Acquire(invocation.Context, ConfigRequest{Operation: ConfigSources, Selection: input.Selection}, contexts[name]) + diagnostics := append([]Diagnostic(nil), acquired.Diagnostics...) + if acquireErr != nil { + return sourceReportFailure(acquireErr, diagnostics, false) + } + if acquired.Value.Sources == nil { + return sourceReportFailure(fmt.Errorf("configuration scope %q did not provide a source reader", name), diagnostics, false) + } + sources := acquired.Value.Sources.Sources() + document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))} + for _, source := range sources { + item := sourceDTO{ + ID: source.ID, + Label: source.Label, + Path: source.Path, + Layer: string(source.Layer), + Kind: string(source.Kind), + UnderlayIndex: source.UnderlayIndex, + } + if source.ImportedFrom != nil { + item.ImportedFrom = &locationDTO{SourceID: source.ImportedFrom.SourceID, Line: source.ImportedFrom.Line, Column: source.ImportedFrom.Column} + } + document.Sources = append(document.Sources, item) + } + + var report bytes.Buffer + if input.Format == "yaml" { + if err := encodeYAML(&report, document); err != nil { + return sourceReportFailure(err, diagnostics, false) + } + } else { + writeSourceReportText(&report, document) + } + if err := writeReport(invocation.Streams.Out, report.Bytes()); err != nil { + outcome := sourceReportFailure(err, diagnostics, true) + outcome.DeliveryError = err + return outcome + } + outcome := Success() + outcome.Diagnostics = diagnostics + return outcome +} + +func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { + _, _ = fmt.Fprintf(writer, "Scope: %s\n", strconv.Quote(document.Scope)) + if len(document.Sources) == 0 { + _, _ = writer.WriteString("Sources: []\n") + return + } + _, _ = writer.WriteString("Sources:\n") + for _, source := range document.Sources { + _, _ = fmt.Fprintf(writer, " - id: %s\n", strconv.Quote(source.ID)) + _, _ = fmt.Fprintf(writer, " label: %s\n", strconv.Quote(source.Label)) + _, _ = fmt.Fprintf(writer, " path: %s\n", strconv.Quote(source.Path)) + _, _ = fmt.Fprintf(writer, " layer: %s\n", strconv.Quote(source.Layer)) + _, _ = fmt.Fprintf(writer, " kind: %s\n", strconv.Quote(source.Kind)) + _, _ = fmt.Fprintf(writer, " underlay_index: %d\n", source.UnderlayIndex) + if source.ImportedFrom == nil { + _, _ = writer.WriteString(" imported_from: null\n") + } else { + _, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n", + strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column) + } + } +} + +func sourceReportFailure(cause error, diagnostics []Diagnostic, reportStarted bool) Outcome { + outcome := Failure(cause) + outcome.Diagnostics = diagnostics + outcome.ReportIncomplete = reportStarted + return outcome +}