Normalize typed CLI history evidence
This commit is contained in:
@@ -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
|
||||
|
||||
156
internal/engine/history_cli.go
Normal file
156
internal/engine/history_cli.go
Normal file
@@ -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
|
||||
}
|
||||
191
internal/engine/history_cli_test.go
Normal file
191
internal/engine/history_cli_test.go
Normal file
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user