Add configuration source reports

This commit is contained in:
2026-10-07 14:05:26 +00:00
parent bbd862311e
commit 25b412ea93
8 changed files with 330 additions and 9 deletions

View File

@@ -6,8 +6,8 @@ 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,
application or CommandKit version reporting, configuration scope discovery, and application or CommandKit version reporting, configuration scope discovery, and
ConfigKit-backed validation and native effective YAML display. Source and ConfigKit-backed validation, native effective YAML display, and source inventory
explanation reports and PromptKit commands remain planned. reports. Explanation reports and PromptKit commands remain planned.
## Documentation ## Documentation

View File

@@ -131,8 +131,8 @@ 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")
} }
commands := make([]Command, 0, 3) commands := make([]Command, 0, 4)
installed := make([]ConfigOperation, 0, 2) installed := make([]ConfigOperation, 0, 3)
if configOperationDeclared(normalized.Scopes, ConfigValidate) { if configOperationDeclared(normalized.Scopes, ConfigValidate) {
validate, err := newConfigValidateCommand(normalized) validate, err := newConfigValidateCommand(normalized)
if err != nil { if err != nil {
@@ -149,6 +149,14 @@ func RegisterConfig[C any](app *App, suite ConfigSuite[C]) error {
commands = append(commands, show) commands = append(commands, show)
installed = append(installed, ConfigShow) 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) command, err := newConfigScopesCommandWithScopes(normalized.Scopes, installed)
if err != nil { if err != nil {
return err return err

View File

@@ -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: validate, show\npromptkit:\nmetadata:\nzeta: show\n" wantText := "app: validate, show\npromptkit: sources\nmetadata:\nzeta: show\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)
} }

View File

@@ -22,6 +22,155 @@ func (document effectiveYAML) WriteEffective(writer io.Writer) error {
return err 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) { func TestConfigShowWritesNativeSingleScopeAndFramesAggregateYAML(t *testing.T) {
const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n" const native = "%YAML 1.1\n# authored comment\n---\nvalue: null\nitems: []\nmissing: {}\n"
app, err := commandkit.New(commandkit.Identity{Name: "example"}) app, err := commandkit.New(commandkit.Identity{Name: "example"})

View File

@@ -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 or output failure may leave an aggregate prefix, and the outcome reports
incomplete report delivery. 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 ## Outcome status
CommandKit usage failures use status `2`; operational failures use status `1`. CommandKit usage failures use status `2`; operational failures use status `1`.

View File

@@ -5,8 +5,8 @@ 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, scope discovery, ConfigKit integration, Configuration suite registration, scope discovery, ConfigKit integration,
validation, and native effective YAML display are implemented; other validation, native effective YAML display, and source inventory reports are
configuration and PromptKit operations remain pending. The 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.

View File

@@ -1,6 +1,6 @@
# Initial CommandKit implementation plan # 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. Date: 2026-10-07.
@@ -645,7 +645,7 @@ passes; README, development orientation, and CLI reference describe the feature.
## Stage 10: implement source inventory reports ## 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. 1. Implement single-scope `config sources`, default text with yaml alternative.
Read the captured SourceReader once. Never reload sources for this report. 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 unsupported all usage preflight, writer failure. Update the source-report CLI
reference with the implemented shape. 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 ## Stage 11: implement path explanation reports
**Status:** pending. **Prerequisite:** Stage 10. **Status:** pending. **Prerequisite:** Stage 10.

128
sources.go Normal file
View File

@@ -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
}