diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index be66f4c..2080df7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Dependency upgrades, discovery, and smoke testing implementation plan -Status: Stages 1–2 complete; Stage 3 is next. +Status: Stages 1–3 complete; Stage 4 is next. ## Authority and baseline @@ -271,6 +271,12 @@ or whole-output snapshots. Run focused source tests and the ckconfig suite. **Complete when:** both source formats expose the agreed native evidence with no parallel ConfigKit behavior. +**Completed:** 2026-10-17. YAML and text `config sources` now project native +destination, placement, unresolved-destination, and source-version evidence. +Focused semantic-YAML and text-field assertions cover the requested presence, +null, redaction, location, and placement cases. Focused source tests and the +complete ckconfig suite passed. + ## Stage 4: Migrate ckprompt production code to PromptKit v0.16.0 **Primary files:** ckprompt manifest/sums, `adapters.go`, `command.go`, diff --git a/integrations/ckconfig/sources.go b/integrations/ckconfig/sources.go index 710c3fe..50f47d3 100644 --- a/integrations/ckconfig/sources.go +++ b/integrations/ckconfig/sources.go @@ -17,13 +17,17 @@ type sourceReport struct { } 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"` + ID string `yaml:"id"` + Label string `yaml:"label"` + Path string `yaml:"path"` + Destination string `yaml:"destination"` + Placement string `yaml:"placement"` + DestinationUnresolved bool `yaml:"destination_unresolved"` + Layer string `yaml:"layer"` + Kind string `yaml:"kind"` + UnderlayIndex int `yaml:"underlay_index"` + ImportedFrom *locationDTO `yaml:"imported_from"` + VersionCheck *sourceVersionCheckDTO `yaml:"version_check"` } type locationDTO struct { @@ -32,6 +36,19 @@ type locationDTO struct { Column int `yaml:"column"` } +type sourceVersionCheckDTO struct { + Path string `yaml:"path"` + Version versionDTO `yaml:"version"` + Location *locationDTO `yaml:"location"` + Redacted bool `yaml:"redacted"` +} + +type versionDTO struct { + Present bool `yaml:"present"` + Null bool `yaml:"null"` + Text string `yaml:"text"` +} + func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOperationPlan[I, S], state S) commandkit.Outcome { selected := plan.scopes[0] name := selected.scope.scope.Name @@ -59,17 +76,39 @@ func runConfigSources[I, S any](invocation commandkit.Invocation, plan *configOp sources := acquired.Value.Sources.Sources() document := sourceReport{Scope: name, Sources: make([]sourceDTO, 0, len(sources))} for _, source := range sources { + placement := "mapping" + if source.PlacementMode == configkit.PlacementModeSequenceItem { + placement = "sequence_item" + } item := sourceDTO{ - ID: source.ID, - Label: source.Label, - Path: source.Path, - Layer: string(source.Layer), - Kind: string(source.Kind), - UnderlayIndex: source.UnderlayIndex, + ID: source.ID, + Label: source.Label, + Path: source.Path, + Destination: source.Destination.String(), + Placement: placement, + DestinationUnresolved: source.DestinationUnresolved, + 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} } + if source.VersionCheck != nil { + check := source.VersionCheck + item.VersionCheck = &sourceVersionCheckDTO{ + Path: check.Path.String(), + Version: versionDTO{ + Present: check.Version.Present, + Null: check.Version.Null, + Text: check.Version.Text, + }, + Redacted: check.Redacted, + } + if check.Location != nil { + item.VersionCheck.Location = &locationDTO{SourceID: check.Location.SourceID, Line: check.Location.Line, Column: check.Location.Column} + } + } document.Sources = append(document.Sources, item) } var report bytes.Buffer @@ -102,6 +141,9 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { _, _ = 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, " destination: %s\n", strconv.Quote(source.Destination)) + _, _ = fmt.Fprintf(writer, " placement: %s\n", strconv.Quote(source.Placement)) + _, _ = fmt.Fprintf(writer, " destination_unresolved: %t\n", source.DestinationUnresolved) _, _ = 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) @@ -111,6 +153,21 @@ func writeSourceReportText(writer *bytes.Buffer, document sourceReport) { _, _ = fmt.Fprintf(writer, " imported_from: {source_id: %s, line: %d, column: %d}\n", strconv.Quote(source.ImportedFrom.SourceID), source.ImportedFrom.Line, source.ImportedFrom.Column) } + if source.VersionCheck == nil { + _, _ = writer.WriteString(" version_check: null\n") + continue + } + check := source.VersionCheck + _, _ = fmt.Fprintf(writer, " version_check:\n path: %s\n", strconv.Quote(check.Path)) + _, _ = fmt.Fprintf(writer, " version:\n present: %t\n null: %t\n text: %s\n", + check.Version.Present, check.Version.Null, strconv.Quote(check.Version.Text)) + if check.Location == nil { + _, _ = writer.WriteString(" location: null\n") + } else { + _, _ = fmt.Fprintf(writer, " location: {source_id: %s, line: %d, column: %d}\n", + strconv.Quote(check.Location.SourceID), check.Location.Line, check.Location.Column) + } + _, _ = fmt.Fprintf(writer, " redacted: %t\n", check.Redacted) } } diff --git a/integrations/ckconfig/sources_test.go b/integrations/ckconfig/sources_test.go new file mode 100644 index 0000000..0e75fc2 --- /dev/null +++ b/integrations/ckconfig/sources_test.go @@ -0,0 +1,134 @@ +package ckconfig + +import ( + "bytes" + "context" + "io" + "reflect" + "strings" + "testing" + + commandkit "gitea.maximumdirect.net/eric/CommandKit" + configkit "gitea.maximumdirect.net/eric/ConfigKit" + "go.yaml.in/yaml/v4" +) + +func TestSourcesProjectNativeEvidenceInTextAndYAML(t *testing.T) { + servicePath, err := configkit.ParsePath("/services") + if err != nil { + t.Fatal(err) + } + otherPath, err := configkit.ParsePath("/other") + if err != nil { + t.Fatal(err) + } + versionPath, err := configkit.ParsePath("/version") + if err != nil { + t.Fatal(err) + } + nullVersionPath, err := configkit.ParsePath("/metadata/version") + if err != nil { + t.Fatal(err) + } + + reader := &sourceInventory{values: []configkit.SourceInfo{ + { + ID: "root", Label: "root source", Path: "root.yml", + PlacementMode: configkit.PlacementModeMapping, + }, + { + ID: "item", Label: "sequence item", Path: "item.yml", + Destination: servicePath, PlacementMode: configkit.PlacementModeSequenceItem, + DestinationUnresolved: true, + VersionCheck: &configkit.SourceVersionCheck{ + Path: versionPath, + Version: configkit.Version{Present: true, Text: "v2"}, + Location: &configkit.Location{SourceID: "item.yml", Line: 4, Column: 2}, + }, + }, + { + ID: "null", Label: "null version", Path: "null.yml", + Destination: otherPath, PlacementMode: configkit.PlacementModeMapping, + VersionCheck: &configkit.SourceVersionCheck{ + Path: nullVersionPath, + Version: configkit.Version{Present: true, Null: true}, + Redacted: true, + }, + }, + }} + app := newConfigTestApp(t, Command(Suite[struct{}, struct{}]{Scopes: []Scope[struct{}, struct{}]{ + {Name: "app", Operations: []Operation{Sources}, Acquire: func(context.Context, Request, struct{}, struct{}) (commandkit.Acquired[View], error) { + return commandkit.Acquired[View]{Value: View{Sources: reader}}, nil + }}, + }})) + + var yamlOutput bytes.Buffer + outcome := app.Execute(context.Background(), []string{"config", "sources", "--format", "yaml"}, commandkit.Streams{Out: &yamlOutput, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 1 { + t.Fatalf("YAML source report outcome=%#v calls=%d", outcome, reader.calls) + } + var report struct { + Scope string `yaml:"scope"` + Sources []struct { + ID string `yaml:"id"` + Destination string `yaml:"destination"` + Placement string `yaml:"placement"` + DestinationUnresolved bool `yaml:"destination_unresolved"` + VersionCheck *struct { + Path string `yaml:"path"` + Version struct { + Present bool `yaml:"present"` + Null bool `yaml:"null"` + Text string `yaml:"text"` + } `yaml:"version"` + Location *struct { + SourceID string `yaml:"source_id"` + Line int `yaml:"line"` + Column int `yaml:"column"` + } `yaml:"location"` + Redacted bool `yaml:"redacted"` + } `yaml:"version_check"` + } `yaml:"sources"` + } + if err := yaml.Unmarshal(yamlOutput.Bytes(), &report); err != nil { + t.Fatalf("decode source report: %v\n%s", err, yamlOutput.String()) + } + if report.Scope != "app" || len(report.Sources) != 3 { + t.Fatalf("source report scope/order = %q, %#v", report.Scope, report.Sources) + } + if got := []string{report.Sources[0].ID, report.Sources[1].ID, report.Sources[2].ID}; !reflect.DeepEqual(got, []string{"root", "item", "null"}) { + t.Fatalf("source order = %v", got) + } + if root := report.Sources[0]; root.Destination != "" || root.Placement != "mapping" || root.DestinationUnresolved || root.VersionCheck != nil { + t.Errorf("root source projection = %#v", root) + } + item := report.Sources[1] + if item.Destination != "/services" || item.Placement != "sequence_item" || !item.DestinationUnresolved { + t.Errorf("sequence item projection = %#v", item) + } + if check := item.VersionCheck; check == nil || check.Path != "/version" || !check.Version.Present || check.Version.Null || check.Version.Text != "v2" || check.Location == nil || check.Location.SourceID != "item.yml" || check.Location.Line != 4 || check.Location.Column != 2 || check.Redacted { + t.Errorf("text version evidence projection = %#v", check) + } + nullSource := report.Sources[2] + if nullSource.Destination != "/other" || nullSource.Placement != "mapping" || nullSource.DestinationUnresolved { + t.Errorf("mapping projection = %#v", nullSource) + } + if check := nullSource.VersionCheck; check == nil || check.Path != "/metadata/version" || !check.Version.Present || !check.Version.Null || check.Version.Text != "" || check.Location != nil || !check.Redacted { + t.Errorf("null/redacted version evidence projection = %#v", check) + } + + var textOutput bytes.Buffer + outcome = app.Execute(context.Background(), []string{"config", "sources", "--format", "text"}, commandkit.Streams{Out: &textOutput, Err: io.Discard}) + if outcome.Kind != commandkit.OutcomeSuccess || reader.calls != 2 { + t.Fatalf("text source report outcome=%#v calls=%d", outcome, reader.calls) + } + for _, fragment := range []string{ + `id: "root"`, `destination: ""`, `placement: "mapping"`, "destination_unresolved: false", "version_check: null", + `destination: "/services"`, `placement: "sequence_item"`, "destination_unresolved: true", `path: "/version"`, `text: "v2"`, + `destination: "/other"`, `path: "/metadata/version"`, "null: true", `text: ""`, "location: null", "redacted: true", + } { + if !strings.Contains(textOutput.String(), fragment) { + t.Errorf("text report missing %q:\n%s", fragment, textOutput.String()) + } + } +}