From 467bbba3e1b4106c85e898ea52cf15639ae416e8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 13 Sep 2026 17:16:06 +0000 Subject: [PATCH] Add configuration inspection commands --- docs/cli.md | 24 ++++++- docs/config.md | 9 +++ docs/roadmap/implementation.md | 2 + internal/cli/configkit_commands.go | 57 +++++++++++++++ internal/cli/configkit_commands_test.go | 96 +++++++++++++++++++++++++ internal/cli/run.go | 6 ++ 6 files changed, 193 insertions(+), 1 deletion(-) diff --git a/docs/cli.md b/docs/cli.md index 9166a936..98a719f3 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -181,6 +181,28 @@ OPENROUTER_API_KEY=validation-placeholder \ The placeholder in the second command is sufficient only for offline validation; it cannot run a provider-backed pipeline. +## config effective + +~~~ +notarius config effective [--config path/to/config.yml] [--config-profile name] +~~~ + +This command writes the selected composed configuration as deterministic, +redacted YAML. Standard output contains only that YAML: it does not validate a +pipeline, prepare modules, materialize references, or contact a provider. + +## config explain + +~~~ +notarius config explain [--config path/to/config.yml] [--config-profile name] +~~~ + +This command writes ConfigKit's redacted, human-readable explanation for one +canonical JSON Pointer. Supply `""` explicitly to explain the root. A malformed +pointer is a syntax error; a well-formed unknown path is a command failure. +The explanation can describe a value displaced by a selected profile. Standard +output contains only the explanation. + ## pipelines list ~~~ @@ -212,6 +234,6 @@ standard error. Parse the result only after the process exits with status 0. | 2 | The command or flag syntax was invalid, including unknown commands, missing required arguments, invalid flag values, or invalid flag combinations. | The root help spellings are the supported help path. Invoking **--help** on -**run**, **config validate**, or **pipelines list** is handled by the flag +**run**, a **config** subcommand, or **pipelines list** is handled by the flag parser as a usage error: it writes an error to standard error and exits with status 2. diff --git a/docs/config.md b/docs/config.md index 072a65ff..9430cccb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -593,3 +593,12 @@ defaults or explicit CLI overrides, incompatible module keys, unknown options, invalid reference bindings, missing required reference slots, invalid validator overrides, and incompatible generated artifact handoffs. Use [pipelines list](cli.md#pipelines-list) to inspect configured IDs. + +## Inspection + +Use [config effective](cli.md#config-effective) to write the selected composed +configuration as deterministic, redacted YAML. Use +[config explain](cli.md#config-explain) with a canonical JSON Pointer to inspect +the effective or historical provenance for one path, including a value displaced +by a selected profile. Neither command resolves a pipeline, prepares modules, +materializes references, or requires provider credentials. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a7d1a60d..e9eacc1e 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -532,6 +532,8 @@ helpers, and debug writer must already be complete in earlier stages. ## Stage 11 — Add Effective And Explanation Commands +**Status:** Completed 2026-09-13. + **Goal:** Expose the accepted operator inspection workflow. **Work:** diff --git a/internal/cli/configkit_commands.go b/internal/cli/configkit_commands.go index a4cb5fcf..93ad053d 100644 --- a/internal/cli/configkit_commands.go +++ b/internal/cli/configkit_commands.go @@ -8,9 +8,66 @@ import ( "io" "strings" + configkit "gitea.maximumdirect.net/eric/ConfigKit" "gitea.maximumdirect.net/eric/notarius/internal/core/config" ) +func runConfigEffective(args []string, stdout, stderr io.Writer, opts Options) int { + fs := flag.NewFlagSet("config effective", flag.ContinueOnError) + fs.SetOutput(io.Discard) + configPath := fs.String("config", "", "config file path") + profile := fs.String("config-profile", "", "configuration profile") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 2 + } + if fs.NArg() != 0 { + fmt.Fprintf(stderr, "notarius: unexpected argument %q\n", fs.Arg(0)) + return 2 + } + loaded, _, err := loadConfigKit(*configPath, *profile, nil, opts) + if err != nil { + writeConfigLoadError(stderr, err) + return 1 + } + if err := loaded.WriteEffective(stdout); err != nil { + fmt.Fprintf(stderr, "notarius: write effective configuration: %v\n", err) + return 1 + } + return 0 +} + +func runConfigExplain(args []string, stdout, stderr io.Writer, opts Options) int { + fs := flag.NewFlagSet("config explain", flag.ContinueOnError) + fs.SetOutput(io.Discard) + configPath := fs.String("config", "", "config file path") + profile := fs.String("config-profile", "", "configuration profile") + if err := fs.Parse(args); err != nil { + fmt.Fprintf(stderr, "notarius: %v\n", err) + return 2 + } + if fs.NArg() != 1 { + fmt.Fprintln(stderr, "notarius: config explain requires exactly one JSON Pointer") + return 2 + } + pointer := fs.Arg(0) + parsed, err := configkit.ParsePath(pointer) + if err != nil || parsed.String() != pointer { + fmt.Fprintf(stderr, "notarius: %q is not a canonical JSON Pointer\n", pointer) + return 2 + } + loaded, _, err := loadConfigKit(*configPath, *profile, nil, opts) + if err != nil { + writeConfigLoadError(stderr, err) + return 1 + } + if err := loaded.WriteExplanation(stdout, pointer); err != nil { + fmt.Fprintf(stderr, "notarius: write configuration explanation: %v\n", err) + return 1 + } + return 0 +} + func runConfigValidate(args []string, stdout, stderr io.Writer, opts Options) int { fs := flag.NewFlagSet("config validate", flag.ContinueOnError) fs.SetOutput(io.Discard) diff --git a/internal/cli/configkit_commands_test.go b/internal/cli/configkit_commands_test.go index affe77cb..027171f5 100644 --- a/internal/cli/configkit_commands_test.go +++ b/internal/cli/configkit_commands_test.go @@ -2,9 +2,16 @@ package cli import ( "bytes" + "context" + "errors" + "io" "path/filepath" "strings" "testing" + + "gitea.maximumdirect.net/eric/notarius/internal/core/artifacts" + "gitea.maximumdirect.net/eric/notarius/internal/core/config" + "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) func TestConfigKitReadOnlyCommandsLoadImportsAndProfiles(t *testing.T) { @@ -34,3 +41,92 @@ func TestConfigKitReadOnlyCommandsRejectVersionFour(t *testing.T) { t.Fatalf("list = %d, %q", code, stderr.String()) } } + +func TestConfigurationInspectionCommandsRenderSafeConfigKitViews(t *testing.T) { + t.Parallel() + root := t.TempDir() + path := filepath.Join(root, "notarius.yml") + writeConfigKitFile(t, path, `version: 5 +concurrency: + total_llm: 4 +pipelines: + "0": + input: {module: input} +`) + writeConfigKitFile(t, filepath.Join(root, "profiles", "development.yml"), "concurrency:\n total_llm: 3\n") + writeConfigKitFile(t, filepath.Join(root, "profiles", "retired.yml"), "pipelines: null\n") + + providerCalled := false + opts := commandContractOptions(t) + opts.LLMClientFactory = func(context.Context, config.Config, string, LLMRuntimeOverrides) (contracts.StructuredLLMClient, []artifacts.LLMProfileManifest, error) { + providerCalled = true + return nil, nil, errors.New("LLM client must not be constructed") + } + + var stdout, stderr bytes.Buffer + if code := RunWithOptions([]string{"config", "effective", "--config", path, "--config-profile", "development"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), "total_llm: 3") || stderr.Len() != 0 { + t.Fatalf("effective = %d, %q, %q", code, stdout.String(), stderr.String()) + } + if providerCalled { + t.Fatal("config effective constructed an LLM client") + } + + stdout.Reset() + stderr.Reset() + if code := RunWithOptions([]string{"config", "explain", "--config", path, "--config-profile", "development", "/concurrency/total_llm"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: "/concurrency/total_llm"`) || stderr.Len() != 0 { + t.Fatalf("explain effective = %d, %q, %q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if code := RunWithOptions([]string{"config", "explain", "--config", path, ""}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: ""`) || stderr.Len() != 0 { + t.Fatalf("explain root = %d, %q, %q", code, stdout.String(), stderr.String()) + } + + stdout.Reset() + stderr.Reset() + if code := RunWithOptions([]string{"config", "explain", "--config", path, "--config-profile", "retired", "/pipelines/0"}, &stdout, &stderr, opts); code != 0 || !strings.Contains(stdout.String(), `path: "/pipelines/0"`) || stderr.Len() != 0 { + t.Fatalf("explain removed mapping key = %d, %q, %q", code, stdout.String(), stderr.String()) + } +} + +func TestConfigExplainRejectsInvalidAndUnknownPointers(t *testing.T) { + t.Parallel() + path := writeCommandConfig(t, "session", "other") + for _, test := range []struct { + name string + pointer string + wantCode int + }{ + {name: "missing", wantCode: 2}, + {name: "malformed escape", pointer: "/pipelines~2session", wantCode: 2}, + {name: "unknown", pointer: "/unknown", wantCode: 1}, + {name: "invalid sequence index", pointer: "/pipelines/session/steps/+0", wantCode: 1}, + } { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + args := []string{"config", "explain", "--config", path} + if test.name != "missing" { + args = append(args, test.pointer) + } + if code := RunWithOptions(args, &stdout, &stderr, commandContractOptions(t)); code != test.wantCode || stdout.Len() != 0 || stderr.Len() == 0 { + t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) + } + }) + } +} + +func TestConfigInspectionCommandsReportWriterFailures(t *testing.T) { + t.Parallel() + path := writeCommandConfig(t, "session", "other") + var stderr bytes.Buffer + if code := RunWithOptions([]string{"config", "effective", "--config", path}, failingInspectionWriter{}, &stderr, commandContractOptions(t)); code != 1 || !strings.Contains(stderr.String(), "write effective configuration") { + t.Fatalf("effective writer failure = %d, %q", code, stderr.String()) + } +} + +type failingInspectionWriter struct{} + +func (failingInspectionWriter) Write([]byte) (int, error) { + return 0, io.ErrClosedPipe +} diff --git a/internal/cli/run.go b/internal/cli/run.go index d2f71d65..443343e9 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -35,6 +35,8 @@ const usage = `Usage: notarius --version notarius run --input path/to/source.json [--json] [flags] notarius config validate --config path/to/config.yml [--config-profile name] [--pipeline pipeline-id] [--only lane-a,lane-b] + notarius config effective --config path/to/config.yml [--config-profile name] + notarius config explain --config path/to/config.yml [--config-profile name] notarius pipelines list --config path/to/config.yml [--config-profile name] [--json] ` @@ -1053,6 +1055,10 @@ func runConfig(args []string, stdout, stderr io.Writer, opts Options) int { switch args[0] { case "validate": return runConfigValidate(args[1:], stdout, stderr, opts) + case "effective": + return runConfigEffective(args[1:], stdout, stderr, opts) + case "explain": + return runConfigExplain(args[1:], stdout, stderr, opts) default: fmt.Fprintf(stderr, "notarius: unknown config subcommand %q\n", args[0]) writeUsage(stderr)