diff --git a/docs/cli.md b/docs/cli.md index dd075d8..090cd95 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -421,21 +421,32 @@ for native evidence definitions. ### Explain a configuration path `config explain --path POINTER` explains the effective and historical value at a -JSON Pointer path. `--path` is required; an explicitly empty value selects the -root. The default scope is `app`; `--format yaml` or `--format json` selects +JSON Pointer path. An explicitly empty path selects the root. `config explain +--list` discovers paths when the selected scope supports path discovery. An +invocation with neither mode displays help; the two modes cannot be combined. +The default scope is `app`; `--format yaml` or `--format json` selects structured output, and automatic output uses text. `--scope all` is unsupported. ```text example config explain --path /server/addr example config explain --path /profiles/team~1summary --format yaml example config explain --path= --format yaml +example config explain --list --under /profiles --historical ``` +Listing normally includes current paths. `--historical` adds paths that only +occurred in history; `--under POINTER` selects a subtree, and `--explicit=true` +or `--explicit=false` filters current paths by native explicitness. Historical-only +paths do not match an explicitness filter. These options require `--list`. +The report contains the scope and each selected path's current presence and, +when present, its explicitness. Path discovery does not include values or history. + Pointer tokens use JSON Pointer escaping: `~1` represents `/` and `~0` represents `~`. A malformed pointer is a usage error with status `2` and is rejected before acquisition. A well-formed path that is unknown in the selected -configuration is an operational error with status `1`. Text output comes from -the scope's native explanation writer. +configuration is an operational error with status `1`. Text output includes +the scope's native explanation and available field metadata. Structured output +includes metadata when supplied by the scope. Structured output uses these root fields: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c7d56a0..0afad92 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Shared reporting and PromptKit v0.20.0 implementation plan -Status: implementation in progress; Stages 1–16 complete. +Status: implementation in progress; Stages 1–17 complete. ## Scope, authority, and execution @@ -610,6 +610,12 @@ algorithms remain upstream tests. ## Stage 17: Explanation discovery and metadata +**Complete.** Explain supports discovery filters and field metadata, with +mode and capability checks before acquisition. `Invocation.Help` renders the +selected command's existing help when no mode is selected, avoiding application +factories. Root, ckconfig, and ckprompt workspace suites passed with Go 1.27.0, +including historical paths, escaped subtrees, and help delivery. + Extend `config explain` under [OD-4](promptkit-v0.20.0.md#od-4-configuration-path-and-metadata-presentation). Track supplied `--path` separately from its value; root `--path ''` is an operation. Bare invocation shows successful command help before input/state diff --git a/execute.go b/execute.go index a302061..6f7d2c6 100644 --- a/execute.go +++ b/execute.go @@ -107,6 +107,7 @@ func (current *execution) invocation() Invocation { reporter: current.reporter, enteredPath: current.route.enteredPath.String(), canonicalPath: current.route.canonicalPath.String(), + renderHelp: func() Outcome { return current.renderHelp(nil) }, } } diff --git a/identity.go b/identity.go index 15e0be9..7bd5394 100644 --- a/identity.go +++ b/identity.go @@ -52,12 +52,22 @@ type Invocation struct { reporter *Reporter enteredPath string canonicalPath string + renderHelp func() Outcome } // Reporter returns the reporting facility supplied for this invocation. A // manually constructed Invocation has no reporter. func (inv Invocation) Reporter() *Reporter { return inv.reporter } +// Help renders the selected command's help through this invocation's output. +// It returns an operational failure when the invocation has no help renderer. +func (inv Invocation) Help() Outcome { + if inv.renderHelp == nil { + return Failure("Command help is unavailable.") + } + return inv.renderHelp() +} + // EnteredPath returns the command path used to invoke the selected command, // excluding the executable name, flags, and operands. It is empty for an // Invocation that was not created by command execution. diff --git a/integrations/ckconfig/discovery_test.go b/integrations/ckconfig/discovery_test.go new file mode 100644 index 0000000..15fe861 --- /dev/null +++ b/integrations/ckconfig/discovery_test.go @@ -0,0 +1,116 @@ +package ckconfig + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + commandkit "go.maximumdirect.net/commandkit" + configkit "go.maximumdirect.net/configkit" +) + +func TestExplainDiscoveryModesAndMetadata(t *testing.T) { + loads, inputs, states := 0, 0, 0 + scope := FromConfigKit("app", "", func(_ context.Context, selection Selection, _, _ struct{}) (*configkit.Result[map[string]any], error) { + loads++ + return configkit.Load(configkit.Mapping("fixture", map[string]any{"a/b": map[string]any{"x": 1}, "ab": 2, "abacus": 3, "gone": map[string]any{"x": 1}}), configkit.Options[map[string]any]{Overrides: selection.Overrides}) + }) + scope.Features.Overrides = true + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}})) + run := func(args ...string) (commandkit.Outcome, string) { + var out bytes.Buffer + result := app.Execute(context.Background(), append([]string{"config", "explain"}, args...), commandkit.Streams{Out: &out, Err: io.Discard}) + return result, out.String() + } + result, out := run() + if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, "--list") || !strings.Contains(out, "--path") || loads != 0 || inputs != 0 || states != 0 { + t.Fatalf("bare help: %#v %q, %d/%d/%d", result, out, loads, inputs, states) + } + for _, args := range [][]string{{"--list", "--path=/ab"}, {"--historical"}, {"--under=/ab"}, {"--explicit=false"}, {"--path=/ab", "--under=/ab"}, {"--list", "--under=bad"}} { + result, _ := run(args...) + if result.Kind != commandkit.OutcomeUsage || loads != 0 || inputs != 0 || states != 0 { + t.Fatalf("invalid %v: %#v, %d/%d/%d", args, result, loads, inputs, states) + } + } + result, out = run("--list", "--under=/a~1b", "--format=json") + var report pathDiscoveryReport + if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 2 { + t.Fatalf("escaped subtree: %#v %q", result, out) + } + seen := map[string]bool{} + for _, entry := range report.Paths { + seen[entry.Path] = true + } + if !seen["/a~1b"] || !seen["/a~1b/x"] { + t.Fatalf("escaped paths: %#v", report.Paths) + } + result, out = run("--list", "--under=/ab", "--format=json") + if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil || len(report.Paths) != 1 || report.Paths[0].Path != "/ab" { + t.Fatalf("token boundary: %#v %q", result, out) + } + result, out = run("--list", "--historical", "--set=/gone=null", "--format=json") + if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil { + t.Fatalf("historical: %#v %q", result, out) + } + removed := false + for _, entry := range report.Paths { + if entry.Path == "/gone/x" && !entry.Present && entry.Metadata == nil { + removed = true + } + } + if !removed { + t.Fatalf("missing historical entry: %q", out) + } + result, out = run("--list", "--historical", "--explicit=false", "--set=/gone=null", "--format=json") + if result.Kind != commandkit.OutcomeSuccess || json.Unmarshal([]byte(out), &report) != nil { + t.Fatalf("explicit filter: %#v %q", result, out) + } + for _, entry := range report.Paths { + if !entry.Present || entry.Metadata == nil || entry.Metadata.Explicit { + t.Fatalf("invalid filtered entry: %#v", entry) + } + } + result, out = run("--path=", "--format=json") + if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"path": ""`) || !strings.Contains(out, `"metadata"`) { + t.Fatalf("root explanation: %#v %q", result, out) + } + result, out = run("--path=/gone/x", "--set=/gone=null", "--format=json") + if result.Kind != commandkit.OutcomeSuccess || !strings.Contains(out, `"metadata"`) || !strings.Contains(out, `"history"`) { + t.Fatalf("historical explanation: %#v %q", result, out) + } +} + +func TestExplainDiscoveryCapabilityBeforeAcquisition(t *testing.T) { + calls := 0 + scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}})) + result := app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeUsage || calls != 0 { + t.Fatalf("unsupported discovery: %#v calls=%d", result, calls) + } + scope.Features.PathDiscovery = true + app = newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{scope}})) + result = app.Execute(context.Background(), []string{"config", "explain", "--list"}, commandkit.Streams{Out: io.Discard, Err: io.Discard}) + if result.Kind != commandkit.OutcomeOperational || calls != 1 { + t.Fatalf("missing metadata: %#v calls=%d", result, calls) + } +} + +func TestExplainBareHelpRecordsDeliveryFailure(t *testing.T) { + inputs, states, calls := 0, 0, 0 + scope := Scope[struct{}, struct{}]{Name: "app", Operations: []Operation{Explain}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + calls++ + return commandkit.Acquired[View]{}, nil + }} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{NewInput: func() struct{} { inputs++; return struct{}{} }, NewState: func() struct{} { states++; return struct{}{} }, Scopes: []Scope[struct{}, struct{}]{scope}})) + result := app.Execute(context.Background(), []string{"config", "explain"}, commandkit.Streams{Out: shortWriter{}, Err: io.Discard}) + if result.Presentation.Output.State != commandkit.DeliveryIncomplete || inputs != 0 || states != 0 || calls != 0 { + t.Fatalf("help delivery = %#v, input/state/acquire=%d/%d/%d", result, inputs, states, calls) + } +} diff --git a/integrations/ckconfig/explain.go b/integrations/ckconfig/explain.go index 48fd19b..84125d9 100644 --- a/integrations/ckconfig/explain.go +++ b/integrations/ckconfig/explain.go @@ -3,6 +3,7 @@ package ckconfig import ( "bytes" "fmt" + "strings" "text/template" commandkit "go.maximumdirect.net/commandkit" @@ -16,8 +17,37 @@ type explanationReport struct { Origin explanationOriginDTO `json:"origin" yaml:"origin"` History []explanationChangeDTO `json:"history" yaml:"history"` SecretReference *secretReferenceDTO `json:"secret_reference" yaml:"secret_reference"` + Metadata *explanationMetadata `json:"metadata,omitempty"` } +type explanationMetadata struct { + Explicit *bool `json:"explicit,omitempty"` + Null *bool `json:"null,omitempty"` + History []metadataHistoryRecord `json:"history,omitempty"` +} + +type metadataHistoryRecord struct { + Path string `json:"path"` + Previous []explanationOriginDTO `json:"previous,omitempty"` +} + +type pathDiscoveryReport struct { + Scope string `json:"scope"` + Paths []pathDiscoveryEntry `json:"paths"` +} + +type pathDiscoveryEntry struct { + Path string `json:"path"` + Present bool `json:"present"` + Metadata *pathMetadata `json:"metadata,omitempty"` +} + +type pathMetadata struct { + Explicit bool `json:"explicit"` +} + +var pathDiscoveryTemplate = template.Must(commandkit.ParseTextTemplate("paths", "{{range .Paths}}{{printf \"%q\" .Path}} {{if .Present}}{{if .Metadata.Explicit}}explicit{{else}}non-explicit{{end}}{{else}}historical{{end}}\n{{end}}", nil)) + type snapshotDTO struct { Present bool `json:"present" yaml:"present"` Redacted bool `json:"redacted" yaml:"redacted"` @@ -72,6 +102,31 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf if err := invocation.Context.Err(); err != nil { return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) } + if plan.list { + if acquired.Value.Metadata == nil { + return configFailure("Configuration scope did not provide path metadata.", acquired.Explanation, fmt.Errorf("configuration scope %q declared path discovery without metadata", name)) + } + report := pathDiscoveryReport{Scope: name, Paths: []pathDiscoveryEntry{}} + for _, path := range acquired.Value.Metadata.HistoricalPaths() { + if plan.under != nil && !pathWithin(path, *plan.under) { + continue + } + info, present := acquired.Value.Metadata.Lookup(path) + if !present && (!plan.historical || plan.explicit.Set) { + continue + } + if present && plan.explicit.Set && info.Explicit != plan.explicit.Value { + continue + } + entry := pathDiscoveryEntry{Path: path.String(), Present: present} + if present { + entry.Metadata = &pathMetadata{Explicit: info.Explicit} + } + report.Paths = append(report.Paths, entry) + } + _ = reporter.Report(commandkit.Report{Value: report, TextTemplate: pathDiscoveryTemplate}) + return commandkit.Success() + } if acquired.Value.Explanation == nil { cause := fmt.Errorf("configuration scope %q did not provide an explanation reader", name) return configFailure("Configuration scope did not provide explanation data.", acquired.Explanation, cause) @@ -85,20 +140,59 @@ func runConfigExplanation[I, S any](invocation commandkit.Invocation, plan *conf if err := invocation.Context.Err(); err != nil { return configFailure("Configuration explanation was canceled.", acquired.Explanation, err) } + report := makeExplanationReport(name, explanation) + if acquired.Value.Metadata != nil { + metadata := &explanationMetadata{} + if info, ok := acquired.Value.Metadata.Lookup(plan.path); ok { + metadata.Explicit, metadata.Null = &info.Explicit, &info.Null + } + for _, change := range acquired.Value.Metadata.History(plan.path) { + entry := metadataHistoryRecord{Path: change.Path.String()} + for _, previous := range change.Previous { + entry.Previous = append(entry.Previous, originReport(previous)) + } + metadata.History = append(metadata.History, entry) + } + if metadata.Explicit != nil || len(metadata.History) > 0 { + report.Metadata = metadata + } + } text := template.Must(commandkit.ParseTextTemplate("explanation", "{{render .}}", template.FuncMap{ - "render": func(explanationReport) (string, error) { + "render": func(report explanationReport) (string, error) { var output bytes.Buffer err := acquired.Value.Explanation.WriteExplanation(&output, plan.path) + if err == nil && report.Metadata != nil && report.Metadata.Explicit != nil { + _, err = fmt.Fprintf(&output, "Explicit: %t\nNull: %t\n", *report.Metadata.Explicit, *report.Metadata.Null) + } return output.String(), err }, })) - renderErr := reporter.Report(commandkit.Report{Value: makeExplanationReport(name, explanation), TextTemplate: text}) + renderErr := reporter.Report(commandkit.Report{Value: report, TextTemplate: text}) if renderErr != nil && invocation.Context.Err() != nil { return configFailure("Configuration explanation was canceled.", acquired.Explanation, invocation.Context.Err()) } return commandkit.Success() } +func pathWithin(path, parent configkit.Path) bool { + tokens := func(value string) []string { + if value == "" { + return nil + } + return strings.Split(strings.TrimPrefix(value, "/"), "/") + } + child, prefix := tokens(path.String()), tokens(parent.String()) + if len(prefix) > len(child) { + return false + } + for index := range prefix { + if prefix[index] != child[index] { + return false + } + } + return true +} + func makeExplanationReport(scope string, explanation configkit.Explanation) explanationReport { report := explanationReport{ Scope: scope, diff --git a/integrations/ckconfig/input.go b/integrations/ckconfig/input.go index a5a18d5..c66c4c9 100644 --- a/integrations/ckconfig/input.go +++ b/integrations/ckconfig/input.go @@ -13,6 +13,10 @@ type configCommandInput[I, S any] struct { against Selection scope commandkit.Supplied[string] path commandkit.Supplied[string] + list commandkit.Supplied[bool] + historical commandkit.Supplied[bool] + under commandkit.Supplied[string] + explicit commandkit.Supplied[bool] values commandkit.FlagValues[I] againstValues commandkit.FlagValues[I] plan *configOperationPlan[I, S] @@ -34,6 +38,11 @@ type configOperationPlan[I, S any] struct { operation Operation scopeName string path configkit.Path + list bool + help bool + historical bool + under *configkit.Path + explicit commandkit.Supplied[bool] scopes []selectedScopeInput[I, S] comparison *preparedComparison[I, S] } @@ -89,8 +98,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio } } if operation == Explain { - flags = append(flags, commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain.", Required: true}, (*string)(nil), parseConfigString, - func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value })) + flags = append(flags, + commandkit.ValueFlag(commandkit.FlagMeta{Name: "path", Usage: "JSON Pointer path to explain."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).path = value }), + commandkit.BoolFlag(commandkit.FlagMeta{Name: "list", Usage: "List configuration paths."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).list = value }), + commandkit.BoolFlag(commandkit.FlagMeta{Name: "historical", Usage: "Include historical-only paths when listing."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).historical = value }), + commandkit.ValueFlag(commandkit.FlagMeta{Name: "under", Usage: "List paths under a JSON Pointer."}, (*string)(nil), parseConfigString, func(input **configCommandInput[I, S], value commandkit.Supplied[string]) { (*input).under = value }), + commandkit.BoolFlag(commandkit.FlagMeta{Name: "explicit", Usage: "Filter listed current paths by explicitness."}, false, func(input **configCommandInput[I, S], value commandkit.Supplied[bool]) { (*input).explicit = value }), + ) } flags = append(flags, commandkit.CaptureFlags(prepared.bindingsByOperation[operation], func(input **configCommandInput[I, S]) *commandkit.FlagValues[I] { if input == nil || *input == nil { @@ -110,8 +124,9 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio name, summary := operationDeclaration(operation) return commandkit.NewCommand(commandkit.CommandSpec[*configCommandInput[I, S]]{ Name: name, Summary: summary, - NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} }, - Flags: flags, + Description: operationDescription(operation), + NewInput: func() *configCommandInput[I, S] { return &configCommandInput[I, S]{} }, + Flags: flags, CheckInput: func(input *configCommandInput[I, S]) error { if input == nil { return fmt.Errorf("configuration input must not be nil") @@ -124,6 +139,13 @@ func newConfigOperationCommand[I, S any](prepared *preparedSuite[I, S], operatio }) } +func operationDescription(operation Operation) string { + if operation == Explain { + return "Use --list to discover paths or --path POINTER to explain one path." + } + return "" +} + func operationDeclaration(operation Operation) (string, string) { switch operation { case Validate: @@ -142,6 +164,18 @@ func operationDeclaration(operation Operation) (string, string) { } func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation Operation, input *configCommandInput[I, S]) error { + if operation == Explain { + if input.list.Value && input.path.Set { + return fmt.Errorf("--list and --path cannot be combined") + } + if !input.list.Value && (input.historical.Set || input.under.Set || input.explicit.Set) { + return fmt.Errorf("--historical, --under, and --explicit require --list") + } + if !input.list.Value && !input.path.Set { + input.plan = &configOperationPlan[I, S]{prepared: prepared, operation: operation, help: true} + return nil + } + } scopeName := "app" if input.scope.Set { @@ -170,26 +204,38 @@ func prepareConfigOperation[I, S any](prepared *preparedSuite[I, S], operation O if (len(input.selection.Overrides) > 0 || len(input.against.Overrides) > 0) && !scope.scope.Features.Overrides { return fmt.Errorf("configuration scope %q does not support overrides", scope.scope.Name) } + if operation == Explain && input.list.Value && !scope.scope.Features.PathDiscovery { + return fmt.Errorf("configuration scope %q does not support path discovery", scope.scope.Name) + } } var path configkit.Path - if operation == Explain { - if !input.path.Set { - return fmt.Errorf("configuration explanation requires --path") - } + if operation == Explain && input.path.Set { parsed, err := configkit.ParsePath(input.path.Value) if err != nil { return fmt.Errorf("invalid configuration path: %w", err) } path = parsed } + var under *configkit.Path + if input.under.Set { + parsed, err := configkit.ParsePath(input.under.Value) + if err != nil { + return fmt.Errorf("invalid subtree path: %w", err) + } + under = &parsed + } plan := &configOperationPlan[I, S]{ - prepared: prepared, - operation: operation, - scopeName: scopeName, - path: path, - scopes: make([]selectedScopeInput[I, S], 0, len(selected)), + prepared: prepared, + operation: operation, + scopeName: scopeName, + path: path, + list: input.list.Value, + historical: input.historical.Value, + under: under, + explicit: input.explicit, + scopes: make([]selectedScopeInput[I, S], 0, len(selected)), } if operation == Diff { scope := selected[0] diff --git a/integrations/ckconfig/operations.go b/integrations/ckconfig/operations.go index f4095a0..9ebc8f1 100644 --- a/integrations/ckconfig/operations.go +++ b/integrations/ckconfig/operations.go @@ -11,6 +11,9 @@ func runConfigOperation[I, S any](invocation commandkit.Invocation, operation Op if input == nil || input.plan == nil { return commandkit.Failure("Configuration operation was not prepared.") } + if input.plan.help { + return invocation.Help() + } if err := invocation.Context.Err(); err != nil { return configFailure("Configuration operation was canceled.", commandkit.Explanation{}, err) }