diff --git a/docs/cli.md b/docs/cli.md index 5e04255..3cf76f5 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -1,8 +1,9 @@ # Audita CLI Reference -The shared version-2 configuration parser is implemented for `process`, `config -validate`, and `config print-effective`. Profile assembly and process execution are -being reconnected in later work, so the CLI is not yet a usable assembled command. +The shared version-2 configuration parser and offline profile inspection are +implemented for `process`, `config validate`, and `config print-effective`. +Process execution is being reconnected in later work, so the CLI is not yet a +usable assembled command. The [configuration reference](config.md) owns fields, defaults, and validation. ## Commands and help @@ -51,10 +52,17 @@ shared LLM section. Legacy configuration environment overrides are ignored. ## Streams and exit status -`config validate` prints `config is valid` on success. `config print-effective` -currently writes ConfigKit's redacted effective-settings YAML; selected-profile -and provenance views are scheduled with profile assembly. The effective YAML is -for inspection and is not guaranteed to be a reloadable input file. +`config validate` loads settings and both selected PromptKit profiles offline, +then prints `config is valid` on success. `config print-effective` accepts +`--view settings|profiles|explain` and defaults to `settings`. Settings writes +ConfigKit's redacted effective-settings YAML. Profiles writes an Audita-owned +YAML summary of selected ID, backend, model, sampling controls, per-attempt +timeout, service tier, and reasoning effort for each role. It omits credentials, +endpoints, and arbitrary extra provider parameters. Explain writes a YAML +sequence of field paths, source origins, and recorded derivations without +resolved setting values. These views share the same settings and profile +validation path as `process`. Unknown views are usage errors. The effective +settings YAML is for inspection and is not guaranteed reloadable. For `process`, `--output` writes the corrected transcript to a file and leaves stdout empty on success. Without it, stdout carries transcript JSON only on @@ -63,8 +71,9 @@ errors and diagnostic locations. A successful run can contain skipped correction or warnings; see the [report contract](integrations/output-files.md). Exit codes are `0` for success, `1` for runtime or output failure, and `2` for -usage or configuration input errors. Configuration commands never contact a -provider merely to parse settings; profile validation is being connected next. +usage or configuration input errors. Invalid settings and profiles return `2`; +broken embedded assets and serialization or write failures return `1`. +Configuration commands inspect profiles without contacting a provider. See the [input files](integrations/transcript-glossary-files.md), [subprocess contract](integrations/subprocess.md), and diff --git a/docs/config.md b/docs/config.md index 6bd94b3..4b78666 100644 --- a/docs/config.md +++ b/docs/config.md @@ -1,8 +1,8 @@ # Audita Configuration This reference describes the version-2 configuration loader in `internal/core/config`. -CLI command parsing and profile inspection are being connected in later work; the -current CLI is not yet a usable entry point for this schema. Complete copyable +CLI command parsing and offline profile inspection are implemented; process +execution is being reconnected in later work. Complete copyable examples will be migrated with that integration. ## Loading and precedence @@ -24,6 +24,15 @@ when that setting can be decoded. ConfigKit retains field origins and derivation for effective-configuration inspection. Its effective YAML redacts resolved credentials. +Profile files are resolved against the selected main file's directory, or may +use an absolute `profiles_dir`. Both selected profiles must exist and select a +registered backend. Their effective endpoints must match that backend's endpoint +after surrounding whitespace and trailing slashes are removed. Profiles cannot +supply a competing `api_key_env` lookup. The backend credential supplies the +runtime key when required; inspection makes no provider call. The +[CLI inspection views](cli.md#streams-and-exit-status) show selected controls and +field provenance separately from ConfigKit's redacted settings YAML. + ## Settings | Field | Default | Rule | @@ -68,5 +77,4 @@ reference resolves only the replacement; null removes the reference. General Secret values are available to the runtime through the resolved reference, while ConfigKit's effective output and provenance contain no resolved secret values. -The CLI reference will be updated as the shared input parser and profile inspection -commands are connected. Current invocation syntax belongs in [CLI](cli.md). +Current invocation syntax belongs in [CLI](cli.md). diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 5c5d379..063f6f6 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -226,7 +226,16 @@ are deliberately migrated in Stages 6–7. ## Stage 4: Assemble Profiles and Finish Offline Inspection -**Status:** Pending. **Depends on:** Stages 1–3. +**Status:** Complete (2026-09-24). Added one engine and profile inspection path +for both roles, requiring registered matching backends and backend-owned +credentials. Config commands and process select that path; settings, safe selected +profiles, and provenance are exposed as separate YAML views with secret redaction. +Focused offline checks passed with `go test internal/framework/llm/runtime.go +internal/framework/llm/safe_view.go internal/framework/llm/runtime_inspection_test.go` +and `go test internal/cli/config_input.go internal/cli/config_input_test.go`; +`git diff --check` passed. Assembled command tests remain deferred until the +old caller packages are reconnected in later stages. +**Depends on:** Stages 1–3. **Owns:** engine/profile assembly in `internal/framework/llm`, CLI config commands, and inspection tests. diff --git a/internal/cli/config_input.go b/internal/cli/config_input.go index 558717c..bff95e9 100644 --- a/internal/cli/config_input.go +++ b/internal/cli/config_input.go @@ -19,6 +19,7 @@ type configInput struct { ConfigPath string ConfigPathSet bool Overlay string + View string TranscriptPath string GlossaryPath string OutputPath string @@ -40,6 +41,9 @@ func parseConfigInput(command string, args []string, output io.Writer) (configIn fs.SetOutput(output) configPath := fs.String("config", "", "Main configuration YAML file") overlay := fs.String("overlay", "", "Selected configuration overlay") + if command == "print-effective" { + fs.String("view", "settings", "Effective view: settings, profiles, or explain") + } settingPaths := map[string]ck.Path{ "proposal-profile": ck.Keys("llm", "proposal_profile"), "validation-profile": ck.Keys("llm", "validation_profile"), @@ -106,6 +110,14 @@ func parseConfigInput(command string, args []string, output io.Writer) (configIn return configInput{}, false, fmt.Errorf("unexpected positional arguments") } input.ConfigPath, input.Overlay = *configPath, *overlay + if command == "print-effective" { + input.View = fs.Lookup("view").Value.String() + switch input.View { + case "settings", "profiles", "explain": + default: + return configInput{}, false, fmt.Errorf("unknown effective view %q", input.View) + } + } fs.Visit(func(f *flag.Flag) { if f.Name == "config" { input.ConfigPathSet = true diff --git a/internal/cli/config_input_test.go b/internal/cli/config_input_test.go index cc8af75..eeb5dd1 100644 --- a/internal/cli/config_input_test.go +++ b/internal/cli/config_input_test.go @@ -108,6 +108,21 @@ func TestLegacyEnvironmentDoesNotSelectConfig(t *testing.T) { } } +func TestEffectiveViewParsing(t *testing.T) { + for _, view := range []string{"settings", "profiles", "explain"} { + input, _, err := parseConfigInput("print-effective", []string{"--view", view}, io.Discard) + if err != nil || input.View != view { + t.Fatalf("%q: %+v %v", view, input, err) + } + } + if _, _, err := parseConfigInput("print-effective", []string{"--view", "raw"}, io.Discard); err == nil { + t.Fatal("unknown view accepted") + } + if _, _, err := parseConfigInput("validate", []string{"--view", "profiles"}, io.Discard); err == nil { + t.Fatal("validate accepted inspection-only flag") + } +} + func TestSharedLoadingUsesSelectedFileOverlayAndVisitedOverrides(t *testing.T) { dir := t.TempDir() if err := os.Mkdir(filepath.Join(dir, "overlays"), 0700); err != nil { diff --git a/internal/cli/run.go b/internal/cli/run.go index bfdd472..588a518 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -52,6 +52,7 @@ type processInvocation struct { ReportJSONPath string Config config.Config ConfigResult *ck.Result[config.Config] + LLMRuntime *llm.Runtime ConfigPath string ConfigSource string ConfigVersion *int @@ -392,6 +393,14 @@ func runProcess(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "audita process: %s\n", configInputError(err)) return 2 } + llmRuntime, err := llm.NewRuntime(context.Background(), selected.Result.Config, selected.Path, nil) + if err != nil { + fmt.Fprintf(stderr, "audita process: %s\n", llm.RedactError(err, selected.Result.Config)) + if llm.IsAssetError(err) { + return 1 + } + return 2 + } version := selected.Result.Config.Version explicitModules := false for _, override := range input.Overrides { @@ -407,6 +416,7 @@ func runProcess(args []string, stdout, stderr io.Writer) int { ReportJSONPath: input.ReportPath, Config: selected.Result.Config, ConfigResult: selected.Result, + LLMRuntime: llmRuntime, ConfigPath: selected.Path, ConfigSource: selected.Source, ConfigVersion: &version, @@ -516,10 +526,18 @@ func runConfigValidate(args []string, stdout, stderr io.Writer) int { writeConfigValidateUsage(stdout) return 0 } - if _, err := loadConfigInput(input, "validate"); err != nil { + selected, err := loadConfigInput(input, "validate") + if err != nil { fmt.Fprintf(stderr, "audita config validate: %s\n", configInputError(err)) return 2 } + if _, err := llm.NewRuntime(context.Background(), selected.Result.Config, selected.Path, nil); err != nil { + fmt.Fprintf(stderr, "audita config validate: %s\n", llm.RedactError(err, selected.Result.Config)) + if llm.IsAssetError(err) { + return 1 + } + return 2 + } fmt.Fprintln(stdout, "config is valid") return 0 } @@ -539,7 +557,28 @@ func runConfigPrintEffective(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "audita config print-effective: %s\n", configInputError(err)) return 2 } - if err := selected.Result.WriteEffective(stdout); err != nil { + llmRuntime, err := llm.NewRuntime(context.Background(), selected.Result.Config, selected.Path, nil) + if err != nil { + fmt.Fprintf(stderr, "audita config print-effective: %s\n", llm.RedactError(err, selected.Result.Config)) + if llm.IsAssetError(err) { + return 1 + } + return 2 + } + if input.View == "settings" { + err = selected.Result.WriteEffective(stdout) + } else { + var output []byte + if input.View == "profiles" { + output, err = llmRuntime.SelectedProfilesYAML() + } else { + output, err = llm.ExplanationYAML(selected.Result) + } + if err == nil { + _, err = stdout.Write(output) + } + } + if err != nil { fmt.Fprintf(stderr, "audita config print-effective: %v\n", err) return 1 } diff --git a/internal/framework/llm/runtime.go b/internal/framework/llm/runtime.go new file mode 100644 index 0000000..d5ea06b --- /dev/null +++ b/internal/framework/llm/runtime.go @@ -0,0 +1,108 @@ +package llm + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + "path/filepath" + "sort" + "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/config" + "gitea.maximumdirect.net/eric/audita/internal/framework/responseschema" + "gitea.maximumdirect.net/eric/audita/internal/prompts" + pk "gitea.maximumdirect.net/eric/promptkit" +) + +// Runtime owns one PromptKit engine and the validated selections for both roles. +// The same engine is shared by the role-bound clients during processing. +type Runtime struct { + Config config.Config + Engine *pk.Engine + Profiles map[string]pk.ProfileInspection +} + +// AssetError indicates a broken embedded prompt or schema rather than an +// operator-provided setting or profile. +type AssetError struct{ Err error } + +func (e *AssetError) Error() string { return e.Err.Error() } +func (e *AssetError) Unwrap() error { return e.Err } + +func NewRuntime(ctx context.Context, c config.Config, mainPath string, client *http.Client) (*Runtime, error) { + profileDir := c.ProfilesDir + if !filepath.IsAbs(profileDir) { + profileDir = filepath.Join(filepath.Dir(mainPath), profileDir) + } + options := []pk.Option{ + pk.WithPromptFS(prompts.Filesystem(), prompts.AssetRoot), + pk.WithSchemaFS(responseschema.Filesystem(), responseschema.AssetRoot), + pk.WithProfileFS(os.DirFS(profileDir), "."), + } + ids := make([]string, 0, len(c.Backends)) + for id := range c.Backends { + ids = append(ids, id) + } + sort.Strings(ids) + for _, id := range ids { + backend := c.Backends[id] + options = append(options, pk.WithBackend(pk.Backend{ID: id, Endpoint: backend.Endpoint, ConcurrencyLimit: backend.Concurrency, QueueCapacity: &backend.QueueCapacity})) + } + engine, err := pk.NewEngine(pk.Config{HTTPClient: client, TransientRetries: &c.LLM.TransientRetries}, options...) + if err != nil { + return nil, fmt.Errorf("assemble LLM runtime: %w", err) + } + for _, meta := range prompts.RegisteredMetadata() { + if _, err := engine.InspectPrompt(ctx, meta.PromptID, meta.PromptVersion); err != nil { + return nil, &AssetError{Err: fmt.Errorf("inspect embedded prompt %s: %w", meta.PromptID, err)} + } + } + runtime := &Runtime{Config: c, Engine: engine, Profiles: make(map[string]pk.ProfileInspection, 2)} + for _, selection := range []struct{ role, id string }{{"proposal", c.LLM.ProposalProfile}, {"validation", c.LLM.ValidationProfile}} { + profile, err := engine.InspectProfile(ctx, selection.id) + if err != nil { + return nil, fmt.Errorf("inspect %s profile: %w", selection.role, err) + } + target := profile.EffectiveModelParams + backend, ok := c.Backends[target.BackendID] + if !ok { + return nil, fmt.Errorf("%s profile must select a registered application backend", selection.role) + } + endpoint := func(value string) string { return strings.TrimRight(strings.TrimSpace(value), "/") } + if endpoint(target.Endpoint) != endpoint(backend.Endpoint) { + return nil, fmt.Errorf("%s profile endpoint must match its registered backend", selection.role) + } + if target.APIKeyEnv != "" { + return nil, fmt.Errorf("%s profile must use the backend credential, not its own environment lookup", selection.role) + } + if profile.APIKeyRequired && backend.Credential == nil { + return nil, fmt.Errorf("%s profile requires a backend credential", selection.role) + } + runtime.Profiles[selection.role] = *profile + } + return runtime, nil +} + +func IsAssetError(err error) bool { var target *AssetError; return errors.As(err, &target) } + +// RedactError replaces every configured resolved backend secret before a +// library error is surfaced in CLI output or an artifact. +func RedactError(err error, c config.Config) string { + if err == nil { + return "" + } + message := err.Error() + var secrets []string + for _, backend := range c.Backends { + if backend.Credential != nil && backend.Credential.Value() != "" { + secrets = append(secrets, backend.Credential.Value()) + } + } + sort.Slice(secrets, func(i, j int) bool { return len(secrets[i]) > len(secrets[j]) }) + for _, secret := range secrets { + message = strings.ReplaceAll(message, secret, "[REDACTED]") + } + return message +} diff --git a/internal/framework/llm/runtime_inspection_test.go b/internal/framework/llm/runtime_inspection_test.go new file mode 100644 index 0000000..18eec14 --- /dev/null +++ b/internal/framework/llm/runtime_inspection_test.go @@ -0,0 +1,168 @@ +package llm + +import ( + "context" + "errors" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + + ck "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/audita/internal/core/config" +) + +type rejectingTransport struct{ t *testing.T } + +func (r rejectingTransport) RoundTrip(*http.Request) (*http.Response, error) { + r.t.Error("profile inspection contacted a provider") + return nil, errors.New("provider access forbidden") +} + +func profileLayout(t *testing.T, base, overlay string) string { + t.Helper() + dir := t.TempDir() + for _, name := range []string{"profiles", "overlays"} { + if err := os.Mkdir(filepath.Join(dir, name), 0700); err != nil { + t.Fatal(err) + } + } + for name, content := range map[string]string{ + "base": "id: base\nbackend: offline\nmodel: full-model\ntemperature: 0.3\nmax_tokens: 500\ntop_p: 0.8\ntimeout_seconds: 12\nservice_tier: priority\nreasoning_effort: medium\n", + "testing": "id: testing\nbackend: offline\nmodel: economy-model\n", + } { + if err := os.WriteFile(filepath.Join(dir, "profiles", name+".yaml"), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(dir, "config.yml"), []byte(base), 0600); err != nil { + t.Fatal(err) + } + if overlay != "" { + if err := os.WriteFile(filepath.Join(dir, "overlays", "testing.yml"), []byte(overlay), 0600); err != nil { + t.Fatal(err) + } + } + return dir +} + +const profileBase = "version: 2\nbackends:\n offline:\n endpoint: https://offline.invalid/v1/\n credential: SYNTHETIC_KEY\nllm:\n proposal_profile: base\n" + +func TestOfflineProfileAssemblyAndSafeViews(t *testing.T) { + t.Setenv("SYNTHETIC_KEY", "synthetic-value-never-print") + dir := profileLayout(t, profileBase, "llm:\n proposal_profile: testing\n") + client := &http.Client{Transport: rejectingTransport{t}} + for _, tc := range []struct { + name, overlay string + overrides []ck.Override + want string + }{ + {"base", "", nil, "full-model"}, + {"overlay", "testing", nil, "economy-model"}, + {"explicit override", "testing", []ck.Override{{Path: ck.Keys("llm", "proposal_profile"), Value: "base", Flag: "--proposal-profile"}}, "full-model"}, + } { + t.Run(tc.name, func(t *testing.T) { + main := filepath.Join(dir, "config.yml") + loaded, err := config.Load(ck.File(main), tc.overlay, tc.overrides...) + if err != nil { + t.Fatal(err) + } + runtime, err := NewRuntime(context.Background(), loaded.Config, main, client) + if err != nil { + t.Fatal(err) + } + if runtime.Engine == nil || runtime.Profiles["proposal"].EffectiveModelParams.Model != tc.want || runtime.Profiles["validation"].EffectiveModelParams.Model != tc.want { + t.Fatalf("roles not resolved: %+v", runtime.Profiles) + } + profiles, err := runtime.SelectedProfilesYAML() + if err != nil { + t.Fatal(err) + } + explain, err := ExplanationYAML(loaded) + if err != nil { + t.Fatal(err) + } + combined := string(profiles) + string(explain) + if !strings.Contains(combined, tc.want) || !strings.Contains(combined, "/llm/validation_profile") { + t.Fatalf("inspection missing selection/provenance: %s", combined) + } + if strings.Contains(combined, "synthetic-value-never-print") || strings.Contains(combined, "https://offline.invalid") { + t.Fatal("safe views exposed credentials or endpoint") + } + if tc.name == "overlay" && !strings.Contains(combined, "overlay") { + t.Fatal("overlay origin missing") + } + if tc.name == "explicit override" && !strings.Contains(combined, "cli") { + t.Fatal("CLI origin missing") + } + if !strings.Contains(string(explain), "validation uses the final proposal profile when omitted") { + t.Fatal("inherited validation derivation missing") + } + if tc.name == "overlay" && !strings.Contains(string(explain), "source: overlays/testing.yml\n layer: overlay") { + t.Fatal("overlay field source missing") + } + if tc.name == "explicit override" && !strings.Contains(string(explain), "source: --proposal-profile\n layer: cli") { + t.Fatal("explicit profile source missing") + } + if tc.name == "base" && (!strings.Contains(string(profiles), "temperature: 0.3") || !strings.Contains(string(profiles), "reasoning_effort: medium")) { + t.Fatal("selected controls missing") + } + }) + } +} + +func TestProfileAssemblyRejectsUnsafeSelections(t *testing.T) { + t.Setenv("SYNTHETIC_KEY", "synthetic-value-never-print") + dir := profileLayout(t, profileBase, "") + main := filepath.Join(dir, "config.yml") + for _, tc := range []struct{ name, profile string }{ + {"endpoint only", "id: custom\nendpoint: https://offline.invalid/v1\nmodel: model\n"}, + {"mismatched endpoint", "id: custom\nbackend: offline\nendpoint: https://elsewhere.invalid/v1\nmodel: model\n"}, + {"profile credential", "id: custom\nbackend: offline\nmodel: model\napi_key_env: OTHER_KEY\n"}, + } { + t.Run(tc.name, func(t *testing.T) { + if err := os.WriteFile(filepath.Join(dir, "profiles", "custom.yaml"), []byte(tc.profile), 0600); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(ck.File(main), "", ck.Override{Path: ck.Keys("llm", "proposal_profile"), Value: "custom", Flag: "--proposal-profile"}) + if err != nil { + t.Fatal(err) + } + if _, err := NewRuntime(context.Background(), loaded.Config, main, &http.Client{Transport: rejectingTransport{t}}); err == nil { + t.Fatal("unsafe profile accepted") + } + }) + } + loaded, err := config.Load(ck.File(main), "", ck.Override{Path: ck.Keys("llm", "proposal_profile"), Value: "missing", Flag: "--proposal-profile"}) + if err != nil { + t.Fatal(err) + } + if _, err := NewRuntime(context.Background(), loaded.Config, main, nil); err == nil { + t.Fatal("missing profile accepted") + } + if got := RedactError(errors.New("provider: synthetic-value-never-print"), loaded.Config); strings.Contains(got, "synthetic-value-never-print") { + t.Fatalf("secret leaked: %q", got) + } +} + +func TestAbsoluteProfileDirectoryAndMissingSecret(t *testing.T) { + t.Setenv("SYNTHETIC_KEY", "synthetic-value-never-print") + dir := profileLayout(t, profileBase, "") + main := filepath.Join(dir, "config.yml") + absolute := strings.Replace(profileBase, "version: 2\n", "version: 2\nprofiles_dir: "+filepath.Join(dir, "profiles")+"\n", 1) + if err := os.WriteFile(main, []byte(absolute), 0600); err != nil { + t.Fatal(err) + } + loaded, err := config.Load(ck.File(main), "") + if err != nil { + t.Fatal(err) + } + if _, err := NewRuntime(context.Background(), loaded.Config, main, &http.Client{Transport: rejectingTransport{t}}); err != nil { + t.Fatal(err) + } + t.Setenv("SYNTHETIC_KEY", "") + if _, err := config.Load(ck.File(main), ""); err == nil { + t.Fatal("missing surviving secret accepted") + } +} diff --git a/internal/framework/llm/safe_view.go b/internal/framework/llm/safe_view.go new file mode 100644 index 0000000..30b5f4e --- /dev/null +++ b/internal/framework/llm/safe_view.go @@ -0,0 +1,111 @@ +package llm + +import ( + "fmt" + "strings" + + ck "gitea.maximumdirect.net/eric/ConfigKit" + "gitea.maximumdirect.net/eric/audita/internal/core/config" + "gopkg.in/yaml.v3" +) + +type selectedProfileView struct { + ID string `yaml:"id"` + Backend string `yaml:"backend"` + Model string `yaml:"model"` + Temperature float64 `yaml:"temperature"` + MaxTokens int `yaml:"max_tokens"` + TopP float64 `yaml:"top_p"` + TimeoutSeconds int `yaml:"timeout_seconds"` + ServiceTier string `yaml:"service_tier,omitempty"` + ReasoningEffort string `yaml:"reasoning_effort,omitempty"` +} + +// SelectedProfilesYAML exposes selected controls without endpoint, credential, +// or arbitrary extra provider parameters. +func (r *Runtime) SelectedProfilesYAML() ([]byte, error) { + if r == nil { + return nil, fmt.Errorf("LLM runtime is unavailable") + } + view := func(role string) (selectedProfileView, error) { + profile, ok := r.Profiles[role] + if !ok { + return selectedProfileView{}, fmt.Errorf("%s profile is unavailable", role) + } + target := profile.EffectiveModelParams + return selectedProfileView{ID: profile.ProfileID, Backend: target.BackendID, Model: target.Model, + Temperature: target.Temperature, MaxTokens: target.MaxTokens, TopP: target.TopP, + TimeoutSeconds: target.TimeoutSeconds, ServiceTier: target.ServiceTier, ReasoningEffort: target.ReasoningEffort}, nil + } + proposal, err := view("proposal") + if err != nil { + return nil, err + } + validation, err := view("validation") + if err != nil { + return nil, err + } + return yaml.Marshal(struct { + Proposal selectedProfileView `yaml:"proposal"` + Validation selectedProfileView `yaml:"validation"` + }{proposal, validation}) +} + +type derivationView struct { + Operation ck.ChangeKind `yaml:"operation"` + Reason string `yaml:"reason"` + Dependencies []string `yaml:"dependencies,omitempty"` +} +type explanationView struct { + Path string `yaml:"path"` + Source string `yaml:"source"` + Layer ck.Layer `yaml:"layer"` + Derivations []derivationView `yaml:"derivations,omitempty"` +} + +// ExplanationYAML reports only origins and recorded resolution operations. +// It intentionally excludes effective and historical values, including secrets. +func ExplanationYAML(result *ck.Result[config.Config]) ([]byte, error) { + if result == nil { + return nil, fmt.Errorf("configuration result is unavailable") + } + metadata := result.Metadata() + sources := make(map[string]string) + for _, source := range result.Sources() { + sources[source.ID] = source.Label + } + records := make([]explanationView, 0) + paths := metadata.HistoricalPaths() + for i, path := range paths { + if path.String() == "" { + continue + } + if i+1 < len(paths) && strings.HasPrefix(paths[i+1].String(), path.String()+"/") { + continue + } + info, ok := metadata.Lookup(path) + if !ok { + continue + } + record := explanationView{Path: path.String(), Layer: info.Origin.Layer, Source: string(info.Origin.Layer)} + if len(info.Origin.Locations) > 0 { + if label := sources[info.Origin.Locations[0].SourceID]; label != "" { + record.Source = label + } + } + for _, change := range metadata.History(path) { + if change.Origin.Layer != ck.LayerResolution { + continue + } + for _, reason := range change.Reasons { + derivation := derivationView{Operation: change.Origin.Operation, Reason: reason} + for _, dependency := range change.Dependencies { + derivation.Dependencies = append(derivation.Dependencies, dependency.String()) + } + record.Derivations = append(record.Derivations, derivation) + } + } + records = append(records, record) + } + return yaml.Marshal(records) +}