From 231ae668579817758ad9c89bfa59dfe6ebc3ad6e Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 26 Oct 2026 15:36:42 +0000 Subject: [PATCH] Normalize typed CLI history evidence --- docs/roadmap/implementation.md | 7 +- internal/engine/history_cli.go | 156 +++++++++++++++++++++++ internal/engine/history_cli_test.go | 191 ++++++++++++++++++++++++++++ internal/engine/history_model.go | 3 + internal/engine/override_context.go | 12 +- 5 files changed, 366 insertions(+), 3 deletions(-) create mode 100644 internal/engine/history_cli.go create mode 100644 internal/engine/history_cli_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index a5fcd7d..fcfd3b7 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Interface Contract Remediation Implementation Plan -Status: in progress. Stages 1–6 are complete; Stages 7–18 remain pending. +Status: in progress. Stages 1–7 are complete; Stages 8–18 remain pending. Baseline: `f6d54a094a2b5ada5748686620659399dce7ebf0`. Audience: a Luna coding agent executing the stages sequentially. @@ -449,7 +449,10 @@ by a particular public projection. Focused check: ## Stage 7 — Normalize Typed Checkpoints And CLI Evidence Owners: new `history_cli.go`, `override_context.go`, override metadata/replay -helpers. Status: pending. +helpers. Status: complete. Validation: +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -run 'CLI.*History|Explain|Decoding|IntegerVersionInspection|Override' -count=1`, +`GOTOOLCHAIN=local GOPROXY=off GOTELEMETRY=off go test ./internal/engine -count=1`, +and `git diff --check`. 1. Register pre-CLI and post-CLI canonical checkpoints with their origin indexes on the correct sides of the CLI contribution phase. Preserve raw-versus-typed diff --git a/internal/engine/history_cli.go b/internal/engine/history_cli.go new file mode 100644 index 0000000..189f650 --- /dev/null +++ b/internal/engine/history_cli.go @@ -0,0 +1,156 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +type historyCheckpointPayload struct { + root *document.Node + origins map[string]Origin +} + +func (builder *historyBuilder) observePreCLICheckpoint(root *document.Node, origins map[string]Origin) { + builder.observeTypedCheckpoint(historyPhasePreCLI, root, origins, "applied retained defaults and canonical typed decoding") +} + +func (builder *historyBuilder) observePostCLICheckpoint(root *document.Node, origins map[string]Origin) { + builder.observeTypedCheckpoint(historyPhasePostCLI, root, origins, "applied the typed CLI assignment") +} + +func (builder *historyBuilder) observeTypedCheckpoint(phase historyPhase, root *document.Node, origins map[string]Origin, reason string) { + builder.requireOpen() + if phase != historyPhasePreCLI && phase != historyPhasePostCLI { + panic("configkit: typed checkpoint has an invalid history phase") + } + payload := builder.nextPayload(phase) + checkpoint := historyCheckpointPayload{root: document.Clone(root), origins: cloneOriginIndex(origins)} + switch phase { + case historyPhasePreCLI: + if builder.model.preCLICheckpoint != nil || payload.id != 0 { + panic("configkit: pre-CLI checkpoint was already registered") + } + builder.model.preCLICheckpoint = &checkpoint + case historyPhasePostCLI: + if builder.model.postCLICheckpoint != nil || payload.id != 0 { + panic("configkit: post-CLI checkpoint was already registered") + } + builder.model.postCLICheckpoint = &checkpoint + } + + beforeRoot := builder.scratch + paths := unionCLIHistoryPaths(beforeRoot, root) + for _, path := range paths { + beforeNode, before := documentAtPath(beforeRoot, path) + afterNode, after := documentAtPath(root, path) + sensitive := beforeNode != nil && beforeNode.Sensitive || afterNode != nil && afterNode.Sensitive + builder.addObservation(historyObservation{ + path: path, phase: phase, payload: payload, + reportedPath: path, role: historyRoleInventory, + sensitive: sensitive, attributionRef: noHistoryAttribution, + }) + if configurationValuesEqual(beforeNode, afterNode) { + continue + } + attributionRef := noHistoryAttribution + if after { + origin, found := origins[path.String()] + if !found { + origin = Origin{Layer: LayerDefault} + } + origin.Operation = ChangeDecoding + attributionRef = builder.addAttribution(origin, []string{reason}, nil) + } + builder.addObservation(historyObservation{ + path: path, phase: phase, payload: payload, + reportedPath: path, role: historyRoleCheckpoint, + beforePresent: before, afterPresent: after, sensitive: sensitive, + attributionRef: attributionRef, + }) + } + builder.advanceScratch(document.Clone(root)) +} + +func (builder *historyBuilder) observeCLIEvidence(applications []historyApplication, records []overlayRecord) { + builder.requireOpen() + count := max(len(applications), len(records)) + if count == 0 { + return + } + index := new(mappingIndex) + for item := 0; item < count; item++ { + if item < len(applications) { + application := applications[item] + payload := builder.addCLIPayload(&application, nil) + walkHistoryApplication(application, func(_, effective Path, node *document.Node) { + origin := sourceApplicationOrigin(application, node) + attribution := builder.addAttribution(origin, nil, nil) + builder.addObservation(historyObservation{ + path: effective, phase: historyPhaseCLI, payload: payload, + reportedPath: effective, role: historyRoleContribution, + afterPresent: true, sensitive: node.Sensitive, + attributionRef: attribution, + }) + }) + applyHistoryApplication(&builder.scratch, application, index) + } + if item < len(records) { + record := records[item] + payload := builder.addCLIPayload(nil, &record) + attribution := builder.addAttribution(record.change.Origin, nil, nil) + for _, displaced := range record.displaced { + builder.addObservation(historyObservation{ + path: displaced.path, phase: historyPhaseCLI, payload: payload, + reportedPath: record.change.Path, role: historyRoleOperation, + beforePresent: true, attributionRef: attribution, + }) + } + for _, introduced := range record.introduced { + builder.addObservation(historyObservation{ + path: introduced.path, phase: historyPhaseCLI, payload: payload, + reportedPath: record.change.Path, role: historyRoleOperation, + afterPresent: true, sensitive: introduced.sensitive, + attributionRef: attribution, + }) + } + } + } +} + +func (builder *historyBuilder) addCLIPayload(application *historyApplication, record *overlayRecord) historyPayloadRef { + payload := builder.nextPayload(historyPhaseCLI) + if payload.id != len(builder.model.cliPayloads) { + panic("configkit: CLI payload identifiers are out of order") + } + owned := historySourcePayload{} + switch { + case application != nil: + owned.kind = historySourceApplicationPayload + owned.application = cloneHistoryApplications([]historyApplication{*application})[0] + case record != nil: + owned.kind = historySourceOverlayPayload + owned.overlay = cloneOverlayRecords([]overlayRecord{*record})[0] + default: + panic("configkit: CLI payload requires an application or operation") + } + builder.model.cliPayloads = append(builder.model.cliPayloads, owned) + return payload +} + +func unionCLIHistoryPaths(roots ...*document.Node) []Path { + var paths []Path + seen := make(map[string][]Path) + for _, root := range roots { + if root == nil { + continue + } + walkDocument(root, Path{}, func(path Path, _ *document.Node) { + for _, existing := range seen[path.String()] { + if sameConcreteHistoryPath(existing, path) { + return + } + } + owned := path.clone() + paths = append(paths, owned) + seen[path.String()] = append(seen[path.String()], owned) + }) + } + return paths +} diff --git a/internal/engine/history_cli_test.go b/internal/engine/history_cli_test.go new file mode 100644 index 0000000..a89bfce --- /dev/null +++ b/internal/engine/history_cli_test.go @@ -0,0 +1,191 @@ +package engine + +import ( + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestCLIOverrideRecordsTrackConcreteShapeChanges(t *testing.T) { + t.Parallel() + + cases := []struct { + name string + beforeText string + incomingText string + oldPath Path + introducedPath Path + }{ + { + name: "mapping to sequence", + beforeText: "target: {'0': {old: true}}\n", + incomingText: "target: [{new: true}]\n", + oldPath: Keys("target", "0", "old"), + introducedPath: Keys("target").Index(0).Key("new"), + }, + { + name: "sequence to mapping", + beforeText: "target: [{old: true}]\n", + incomingText: "target: {'0': {new: true}}\n", + oldPath: Keys("target").Index(0).Key("old"), + introducedPath: Keys("target", "0", "new"), + }, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + before := parseDocumentForDecode(t, test.beforeText) + incomingRoot := parseDocumentForDecode(t, test.incomingText) + setOverrideTestDocumentSource(incomingRoot, "cli-source") + incoming, _ := documentAtPath(incomingRoot, Keys("target")) + origins := make(map[string]Origin) + collectOverlayOrigins(origins, before, Path{}, LayerBase) + records := overrideMetadataRecords(decodedConfiguration{final: before, origins: origins}, []preparedOverride{{ + path: Keys("target"), value: incoming, source: SourceInfo{ID: "cli-source"}, + }}) + if len(records) != 1 { + t.Fatalf("override records = %#v", records) + } + record := records[0] + if !containsConcreteDisplacement(record.displaced, test.oldPath) { + t.Fatalf("displaced shape %s missing from %#v", test.oldPath, record.displaced) + } + introduced := findHistoryPathOrigin(record.introduced, test.introducedPath) + if introduced == nil || introduced.origin.Layer != LayerCLI || len(introduced.origin.Locations) != 1 || introduced.origin.Locations[0].SourceID != "cli-source" { + t.Fatalf("introduced shape %s has no CLI attribution: %#v", test.introducedPath, record.introduced) + } + application := historyApplication{ + layer: LayerCLI, path: Keys("target"), value: incoming, + origin: overlayOrigin(LayerCLI, document.Location{SourceID: "cli-source"}), + } + builder := newHistoryBuilder() + builder.setScratch(before) + builder.observeCLIEvidence([]historyApplication{application}, records) + model := builder.freeze() + oldSelections := model.selectHistory(test.oldPath) + if len(oldSelections) != 1 || oldSelections[0].role != historyRoleOperation || !oldSelections[0].before || oldSelections[0].after { + t.Fatalf("displaced CLI shape %s = %#v", test.oldPath, oldSelections) + } + introducedSelections := model.selectHistory(test.introducedPath) + introducedOperation := false + for _, selection := range introducedSelections { + introducedOperation = introducedOperation || selection.role == historyRoleOperation && !selection.before && selection.after + } + if !introducedOperation { + t.Fatalf("introduced CLI shape %s selections = %#v", test.introducedPath, introducedSelections) + } + }) + } +} + +func TestCLIHistoryEvidenceKeepsCheckpointsAndConditionalReconciliation(t *testing.T) { + t.Parallel() + + structural := parseDocumentForDecode(t, "count: 7\nsettings: {a: 1, b: 2}\nitems: {old: value}\n") + preCLI := parseDocumentForDecode(t, "count: '7'\nsettings: {a: 1, b: 2}\nitems: {old: value}\ndefaulted: 9\n") + preOrigins := map[string]Origin{ + Keys("count").String(): {Layer: LayerBase, Locations: []Location{{SourceID: "input"}}}, + Keys("defaulted").String(): {Layer: LayerDefault}, + } + countRoot := parseDocumentForDecode(t, "count: 7\n") + settingsRoot := parseDocumentForDecode(t, "settings: {a: 1}\n") + itemsRoot := parseDocumentForDecode(t, "items: []\n") + setOverrideTestDocumentSource(countRoot, "cli-count") + setOverrideTestDocumentSource(settingsRoot, "cli-settings") + setOverrideTestDocumentSource(itemsRoot, "cli-items") + countValue, _ := documentAtPath(countRoot, Keys("count")) + settingsValue, _ := documentAtPath(settingsRoot, Keys("settings")) + itemsValue, _ := documentAtPath(itemsRoot, Keys("items")) + overrides := []preparedOverride{ + {path: Keys("count"), value: countValue, source: SourceInfo{ID: "cli-count"}}, + {path: Keys("settings"), value: settingsValue, source: SourceInfo{ID: "cli-settings"}}, + {path: Keys("items"), value: itemsValue, source: SourceInfo{ID: "cli-items"}}, + } + records := overrideMetadataRecords(decodedConfiguration{final: preCLI, origins: preOrigins}, overrides) + applications := make([]historyApplication, len(overrides)) + for index, override := range overrides { + applications[index] = historyApplication{ + layer: LayerCLI, path: override.path, value: override.value, + origin: overlayOrigin(LayerCLI, document.Location{SourceID: override.source.ID}), + } + } + postCLI := parseDocumentForDecode(t, "count: '7'\nsettings: {a: 1, b: 2}\nitems: []\ndefaulted: 9\n") + postOrigins := map[string]Origin{ + Keys("count").String(): {Layer: LayerCLI, Locations: []Location{{SourceID: "cli-count"}}}, + Keys("defaulted").String(): {Layer: LayerDefault}, + } + + builder := newHistoryBuilder() + builder.setScratch(structural) + builder.observePreCLICheckpoint(preCLI, preOrigins) + builder.observeCLIEvidence(applications, records) + builder.observePostCLICheckpoint(postCLI, postOrigins) + preCount, _ := documentAtPath(preCLI, Keys("count")) + postCount, _ := documentAtPath(postCLI, Keys("count")) + preCount.Text = "producer mutation" + postCount.Text = "producer mutation" + model := builder.freeze() + + if model.preCLICheckpoint == nil || model.postCLICheckpoint == nil || + model.preCLICheckpoint.root == preCLI || model.postCLICheckpoint.root == postCLI || + len(model.cliPayloads) != 6 { + t.Fatalf("checkpoint/CLI payload ownership = pre:%#v post:%#v CLI:%d", model.preCLICheckpoint, model.postCLICheckpoint, len(model.cliPayloads)) + } + preValue, _ := documentAtPath(model.preCLICheckpoint.root, Keys("count")) + postValue, _ := documentAtPath(model.postCLICheckpoint.root, Keys("count")) + if preValue.Kind != document.String || preValue.Text != "7" || postValue.Kind != document.String || postValue.Text != "7" { + t.Fatalf("typed checkpoint values = %#v / %#v", preValue, postValue) + } + + count := model.selectHistory(Keys("count")) + var sawPreDecoding, sawEqualCLI, sawPostDecoding bool + for _, selection := range count { + switch selection.phase { + case historyPhasePreCLI: + sawPreDecoding = selection.role == historyRoleCheckpoint && selection.before && selection.after && + selection.attribution.origin.Operation == ChangeDecoding && len(selection.attribution.reasons) != 0 + case historyPhaseCLI: + sawEqualCLI = sawEqualCLI || selection.role == historyRoleOperation && selection.after + case historyPhasePostCLI: + sawPostDecoding = selection.role == historyRoleCheckpoint && selection.before && selection.after && + selection.attribution.origin.Layer == LayerCLI && selection.attribution.origin.Operation == ChangeDecoding + } + } + if !sawPreDecoding || !sawEqualCLI || !sawPostDecoding { + t.Fatalf("checkpoint and equal CLI selections = %#v", count) + } + if metadataHistoryRole(historyRoleCheckpoint) || !metadataHistoryRole(historyRoleOperation) { + t.Fatal("typed reconciliation checkpoint leaked into metadata history roles") + } + + defaulted := model.selectHistory(Keys("defaulted")) + if len(defaulted) != 1 || defaulted[0].phase != historyPhasePreCLI || defaulted[0].before || !defaulted[0].after || + defaulted[0].attribution.origin.Layer != LayerDefault || len(defaulted[0].attribution.reasons) == 0 { + t.Fatalf("retained default checkpoint = %#v", defaulted) + } + removed := model.selectHistory(Keys("items", "old")) + if len(removed) != 1 || removed[0].phase != historyPhaseCLI || removed[0].role != historyRoleOperation || !removed[0].before || removed[0].after { + t.Fatalf("whole-sequence CLI removal = %#v", removed) + } + untouched := model.selectHistory(Keys("settings", "b")) + if len(untouched) != 0 { + t.Fatalf("partial mapping CLI override attributed an untouched key: %#v", untouched) + } +} + +func containsConcreteDisplacement(displaced []displacedDefinition, path Path) bool { + for _, candidate := range displaced { + if sameConcreteHistoryPath(candidate.path, path) { + return true + } + } + return false +} + +func findHistoryPathOrigin(introduced []historyPathOrigin, path Path) *historyPathOrigin { + for index := range introduced { + if sameConcreteHistoryPath(introduced[index].path, path) { + return &introduced[index] + } + } + return nil +} diff --git a/internal/engine/history_model.go b/internal/engine/history_model.go index a79573a..aa2178c 100644 --- a/internal/engine/history_model.go +++ b/internal/engine/history_model.go @@ -60,6 +60,9 @@ type historyModel struct { baseline *document.Node sourcePayloads []historySourcePayload inheritancePayloads []historyInheritancePayload + cliPayloads []historySourcePayload + preCLICheckpoint *historyCheckpointPayload + postCLICheckpoint *historyCheckpointPayload shapesByPointer map[string][]Path shapeOrder []Path diff --git a/internal/engine/override_context.go b/internal/engine/override_context.go index 00f3a9f..c60baef 100644 --- a/internal/engine/override_context.go +++ b/internal/engine/override_context.go @@ -106,12 +106,22 @@ func overrideMetadataRecords(before decodedConfiguration, overrides []preparedOv location := document.Location{SourceID: override.source.ID} previous := cloneOrigin(before.origins[override.path.String()]) var displaced []displacedDefinition + var introduced []historyPathOrigin if existing, ok := documentAtPathWithLookup(before.final, override.path, lookup); ok { displaced = affectedOverrideOrigins(existing, override.value, before.origins, override.path, new(mappingIndex)) } + application := historyApplication{ + layer: LayerCLI, path: override.path.clone(), value: override.value, + origin: overlayOrigin(LayerCLI, location), + } + walkHistoryApplication(application, func(_, effective Path, node *document.Node) { + introduced = append(introduced, historyPathOrigin{ + path: effective, origin: sourceApplicationOrigin(application, node), sensitive: node.Sensitive, + }) + }) records = append(records, overlayRecord{change: Change{ Path: override.path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous), - }, displaced: displaced}) + }, displaced: displaced, introduced: introduced}) } return records }