diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index be26cd8..117d4e2 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation Plan: Sequence Placement and Composition -Status: ready for implementation; no stages completed. +Status: implementation in progress; stages 1–4 completed. ## Purpose and authority @@ -333,6 +333,21 @@ Scope: private history operations, replay, and precedence handoff. Validation: history/explanation/overlay tests and `./scripts/check.sh`. Completion: strict accumulation is distinguishable from precedence replacement. +Completion evidence: history applications now carry owned per-collection +contribution ranges and distinguish each strict tier's first sequence +contribution (replacement) from later appends. Replay preserves source +applications through underlay handoff, maps appended record queries back to +source-local indexes, and supports semantic indexed helper paths. Tests cover +appended and independent collections, origin attribution, detached paths, +explicit empty-list replacement, ordinary whole-list replacement, and tier +handoff. These checks passed with Go `go1.26.9 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'History|Explain|Overlay' -count=1` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 5 — Integrate projected paths with inspection and sensitivity Scope: metadata, retained history traversal, and safe inspection. diff --git a/internal/engine/compose.go b/internal/engine/compose.go index 33ede28..b079a32 100644 --- a/internal/engine/compose.go +++ b/internal/engine/compose.go @@ -16,6 +16,20 @@ type composedDocument struct { inputNodes int } +type sequenceHistoryMode uint8 + +const ( + sequenceHistoryReplace sequenceHistoryMode = iota + 1 + sequenceHistoryAppend +) + +type sequenceHistoryContribution struct { + path Path + mode sequenceHistoryMode + offset int + length int +} + // assemblyAccumulator owns one mutable working document. Acquired trees held by // history applications are separate from its root and remain unchanged. type assemblyAccumulator struct { @@ -113,8 +127,24 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated) } applications := make([]historyApplication, 0, len(inputs)) - for _, input := range inputs { - applications = append(applications, historyApplication{layer: LayerBase, value: input}) + seenSequences := make(map[string]bool) + for sourceIndex, input := range inputs { + application := historyApplication{layer: LayerBase, value: input} + for _, assigned := range ranges { + if assigned.sourceIndex != sourceIndex { + continue + } + pointer := assigned.path.String() + mode := sequenceHistoryAppend + if !seenSequences[pointer] { + mode = sequenceHistoryReplace + } + seenSequences[pointer] = true + application.sequences = append(application.sequences, sequenceHistoryContribution{ + path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start, + }) + } + applications = append(applications, application) } origins := projectedCompositionOrigins(inputs, ranges) completeCompositionOrigins(root, Path{}, origins) diff --git a/internal/engine/history.go b/internal/engine/history.go index 546d407..d2b9b43 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -45,10 +45,11 @@ func (c *retainedNodeCounter) add(root *document.Node) bool { // Replay reconciles with canonical decoding before domain resolution so both // contracts agree on the effective result without retaining a tree per field. type historyApplication struct { - layer Layer - path Path - value *document.Node - origin Origin + layer Layer + path Path + value *document.Node + origin Origin + sequences []sequenceHistoryContribution } func cloneHistoryApplications(values []historyApplication) []historyApplication { @@ -59,7 +60,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication for index, value := range values { cloned[index] = historyApplication{ layer: value.layer, path: value.path.clone(), value: value.value, - origin: cloneOrigin(value.origin), + origin: cloneOrigin(value.origin), sequences: cloneSequenceHistoryContributions(value.sequences), } } return cloned @@ -75,6 +76,24 @@ func applicationOperationPath(application historyApplication, query Path, lookup } return Path{}, false } + sequencePathMatched := false + for _, contribution := range application.sequences { + if !pathContains(contribution.path, query) && !pathContains(query, contribution.path) { + continue + } + sequencePathMatched = true + if pathContains(contribution.path, query) && len(query.segments) > len(contribution.path.segments) { + segment := query.segments[len(contribution.path.segments)] + itemIndex, ok := schemaIndex(segment) + if !ok || itemIndex < contribution.offset || itemIndex >= contribution.offset+contribution.length { + continue + } + } + return query.clone(), true + } + if sequencePathMatched { + return Path{}, false + } if len(query.segments) == 0 { return Path{}, true } @@ -100,6 +119,22 @@ func historyApplicationOrigin(application historyApplication, operationPath Path if !ok { return origin } + for _, contribution := range application.sequences { + if !pathContains(contribution.path, operationPath) { + continue + } + relative, ok = relativeHistoryPath(Path{}, operationPath) + if !ok || len(relative.segments) < len(contribution.path.segments)+1 { + break + } + itemPosition := len(contribution.path.segments) + itemIndex, valid := schemaIndex(relative.segments[itemPosition]) + if !valid || itemIndex < contribution.offset || itemIndex >= contribution.offset+contribution.length { + break + } + relative.segments[itemPosition] = pathSegment{kind: pathSegmentIndex, index: itemIndex - contribution.offset} + break + } if node, present := documentAtPathWithLookup(application.value, relative, lookup); present { origin.Locations = []Location{publicLocation(node.Location)} } @@ -119,13 +154,17 @@ func pathsIntersect(left, right Path) bool { func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) { if application.path.String() == "" { - mergeHistoryDocument(root, application.value, index) + mergeHistoryDocumentAtPath(root, application.value, Path{}, index, application.sequences) return } mergeHistoryAtPath(root, application.path, application.value, index) } func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) { + mergeHistoryDocumentAtPath(target, incoming, Path{}, index, nil) +} + +func mergeHistoryDocumentAtPath(target **document.Node, incoming *document.Node, path Path, index *mappingIndex, sequences []sequenceHistoryContribution) { if incoming == nil { return } @@ -141,10 +180,49 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node, index index.append(*target, cloned) continue } - mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index) + entryPath := path.Key(entry.Key) + if contribution, found := sequenceHistoryAt(sequences, entryPath); found { + applySequenceHistory(&(*target).Mapping[position].Value, entry.Value, contribution) + continue + } + mergeHistoryDocumentAtPath(&(*target).Mapping[position].Value, entry.Value, entryPath, index, sequences) } } +func sequenceHistoryAt(contributions []sequenceHistoryContribution, path Path) (sequenceHistoryContribution, bool) { + for _, contribution := range contributions { + if contribution.path.String() == path.String() { + return contribution, true + } + } + return sequenceHistoryContribution{}, false +} + +func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContribution) { + if incoming == nil || incoming.Kind != document.Sequence { + return + } + if contribution.mode == sequenceHistoryReplace || *target == nil || (*target).Kind != document.Sequence { + *target = document.Clone(incoming) + return + } + for _, item := range incoming.Sequence { + (*target).Sequence = append((*target).Sequence, document.Clone(item)) + } +} + +func cloneSequenceHistoryContributions(values []sequenceHistoryContribution) []sequenceHistoryContribution { + if values == nil { + return nil + } + cloned := make([]sequenceHistoryContribution, len(values)) + for index, value := range values { + cloned[index] = value + cloned[index].path = value.path.clone() + } + return cloned +} + func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) { if root == nil || *root == nil || len(path.segments) == 0 { mergeHistoryDocument(root, incoming, index) @@ -152,7 +230,19 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node } current := *root for segmentIndex, segment := range path.segments { - if current.Kind != document.Mapping || segment.kind == pathSegmentIndex { + if segment.kind == pathSegmentIndex { + position, ok := schemaIndex(segment) + if current.Kind != document.Sequence || !ok || position >= len(current.Sequence) { + return + } + if segmentIndex == len(path.segments)-1 { + mergeHistoryDocument(¤t.Sequence[position], incoming, index) + return + } + current = current.Sequence[position] + continue + } + if current.Kind != document.Mapping { return } position := index.find(current, segment.key) diff --git a/internal/engine/history_sequence_test.go b/internal/engine/history_sequence_test.go new file mode 100644 index 0000000..d667169 --- /dev/null +++ b/internal/engine/history_sequence_test.go @@ -0,0 +1,147 @@ +package engine + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) { + policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}} + first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n") + second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n") + composed, diagnostics := composeDocumentsWithPolicy(policy, first, second) + if len(diagnostics) != 0 { + t.Fatalf("compose diagnostics = %#v", diagnostics) + } + + working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nother: [{id: default}]\n") + index := new(mappingIndex) + for _, application := range composed.applications { + applyHistoryApplication(&working, application, index) + } + for path, want := range map[string][]string{ + "/records": {"a", "b"}, + "/other": {"x", "y"}, + } { + sequence, present := documentAtPath(working, mustParsePath(t, path)) + if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) { + t.Fatalf("replayed %s = %#v", path, sequence) + } + for itemIndex, id := range want { + field := sequence.Sequence[itemIndex].Mapping[0].Value.Text + if field != id { + t.Errorf("replayed %s[%d].id = %q, want %q", path, itemIndex, field, id) + } + } + } + + application := composed.applications[1] + cloned := cloneHistoryApplications(composed.applications) + cloned[1].sequences[0].path.segments[0].key = "mutated" + if composed.applications[1].sequences[0].path.String() != "/records" { + t.Fatal("cloned sequence history path aliases its source") + } + addedPath := Keys("records").Index(1).Key("value") + if operation, relevant := applicationOperationPath(application, addedPath, nil); !relevant || operation.String() != addedPath.String() { + t.Fatalf("appended value operation = %s, %v", operation.String(), relevant) + } + if _, relevant := applicationOperationPath(application, Keys("records").Index(0).Key("value"), nil); relevant { + t.Fatal("later contribution was reported as changing an earlier record") + } + origin := historyApplicationOrigin(application, addedPath, nil) + if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" { + t.Fatalf("appended value origin = %#v", origin) + } + overlay := historyApplication{ + layer: LayerOverlay, + value: parseCompositionDocument(t, "overlay", "records: []\n"), + } + applyHistoryApplication(&working, overlay, index) + replaced, present := documentAtPath(working, Keys("records")) + if !present || replaced.Kind != document.Sequence || len(replaced.Sequence) != 0 { + t.Fatalf("ordinary later list replacement = %#v, %v", replaced, present) + } +} + +func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) { + policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}} + first, diagnostics := composeDocumentsWithPolicy(policy, + parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"), + parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"), + ) + if len(diagnostics) != 0 { + t.Fatalf("first tier diagnostics = %#v", diagnostics) + } + for index := range first.applications { + first.applications[index].layer = LayerUnderlay + } + second, diagnostics := composeDocumentsWithPolicy(policy, + parseCompositionDocument(t, "underlay-2", "records: [{id: shared}]\nindependent: []\n"), + ) + if len(diagnostics) != 0 { + t.Fatalf("second tier diagnostics = %#v", diagnostics) + } + main, diagnostics := composeDocumentsWithPolicy(policy, + parseCompositionDocument(t, "main", "records: []\n"), + ) + if len(diagnostics) != 0 { + t.Fatalf("main diagnostics = %#v", diagnostics) + } + + assembly := newAssemblyAccumulator(first) + if diagnostics := assembly.applyOverlay(second.root, second.origins, second.applications, LayerUnderlay); len(diagnostics) != 0 { + t.Fatalf("second tier handoff diagnostics = %#v", diagnostics) + } + if diagnostics := assembly.applyOverlay(main.root, main.origins, main.applications, LayerBase); len(diagnostics) != 0 { + t.Fatalf("main handoff diagnostics = %#v", diagnostics) + } + if len(assembly.current.applications) != 4 { + t.Fatalf("retained source applications = %d, want 4", len(assembly.current.applications)) + } + + working := parseCompositionDocument(t, "defaults", "records: [{id: default}]\nindependent: [{id: default}]\n") + index := new(mappingIndex) + for _, application := range assembly.current.applications { + applyHistoryApplication(&working, application, index) + } + for path, want := range map[string][]string{ + "/records": nil, + "/independent": nil, + } { + sequence, present := documentAtPath(working, mustParsePath(t, path)) + if !present || sequence.Kind != document.Sequence || len(sequence.Sequence) != len(want) { + t.Errorf("replayed replacement %s = %#v, want an explicit empty sequence", path, sequence) + } + } + + // The same identity may appear in separate tiers, while each tier still + // retains its individual source applications for explanation replay. + if got := []Layer{assembly.current.applications[0].layer, assembly.current.applications[1].layer, + assembly.current.applications[2].layer, assembly.current.applications[3].layer}; !reflect.DeepEqual(got, []Layer{LayerUnderlay, LayerUnderlay, LayerUnderlay, LayerBase}) { + t.Fatalf("application layers = %#v", got) + } +} + +func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) { + root := parseCompositionDocument(t, "base", "records: [{id: one, helper: {value: old}}]\n") + application := historyApplication{ + path: Keys("records").Index(0).Key("helper"), + value: parseCompositionDocument(t, "helper", "value: new\n"), + } + applyHistoryApplication(&root, application, new(mappingIndex)) + value, present := documentAtPath(root, Keys("records").Index(0).Key("helper").Key("value")) + if !present || value.Text != "new" { + t.Fatalf("indexed helper replay = %#v, %v", value, present) + } +} + +func mustParsePath(t *testing.T, value string) Path { + t.Helper() + path, err := ParsePath(value) + if err != nil { + t.Fatal(err) + } + return path +}