From c0a0c753f97d0eecd84b386e9d687b00c70e3b3b Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 2 Nov 2026 14:40:04 +0000 Subject: [PATCH] Match registered collection endpoints for resolution --- docs/roadmap/implementation.md | 2 + internal/engine/resolution_correspondence.go | 115 ++++++++++++++++++ .../engine/resolution_correspondence_test.go | 65 ++++++++++ 3 files changed, 182 insertions(+) create mode 100644 internal/engine/resolution_correspondence.go create mode 100644 internal/engine/resolution_correspondence_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 403ef7c..e978df8 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -100,6 +100,8 @@ implementation completion. No current-behavior reference is updated yet. ## Stage 2 — Build endpoint collection correspondence +**Status:** Complete. Focused correspondence tests cover identity matching, fallback, and dynamic selectors. + **Scope:** private, pure matching logic, preferably in `internal/engine/resolution_correspondence.go`. diff --git a/internal/engine/resolution_correspondence.go b/internal/engine/resolution_correspondence.go new file mode 100644 index 0000000..5ecd32c --- /dev/null +++ b/internal/engine/resolution_correspondence.go @@ -0,0 +1,115 @@ +package engine + +import "go.maximumdirect.net/configkit/internal/document" + +// collectionCorrespondence describes matching at one concrete registered path. +// A negative index denotes an identity present only at the other endpoint. +type collectionCorrespondence struct { + path Path + eligible bool + structural bool + entryToFinal []int + finalToEntry []int +} + +type resolutionCorrespondence struct { + collections map[string]collectionCorrespondence +} + +func buildResolutionCorrespondence(entry, final *document.Node, policy sequencePolicy) resolutionCorrespondence { + result := resolutionCorrespondence{collections: make(map[string]collectionCorrespondence)} + var indexes mappingIndex + var visit func(*document.Node, *document.Node, Path) + visit = func(before, after *document.Node, path Path) { + if fields, registered := policy.identityFieldsAt(path); registered && + ((before != nil && before.Kind == document.Sequence) || (after != nil && after.Kind == document.Sequence)) { + result.collections[path.String()] = matchResolutionCollection(before, after, path, fields) + return + } + if before != nil && before.Kind == document.Mapping { + for _, item := range before.Mapping { + var next *document.Node + if after != nil && after.Kind == document.Mapping { + if position := indexes.find(after, item.Key); position >= 0 { + next = after.Mapping[position].Value + } + } + visit(item.Value, next, path.Key(item.Key)) + } + } + if after != nil && after.Kind == document.Mapping { + for _, item := range after.Mapping { + if before == nil || before.Kind != document.Mapping || indexes.find(before, item.Key) < 0 { + visit(nil, item.Value, path.Key(item.Key)) + } + } + } + } + visit(entry, final, Path{}) + return result +} + +func matchResolutionCollection(before, after *document.Node, path Path, fields []string) collectionCorrespondence { + result := collectionCorrespondence{path: path.clone()} + prior, priorIssues := validateNormalizedSequenceIdentityFrom(before, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord) + later, laterIssues := validateNormalizedSequenceIdentityFrom(after, path, fields, sequenceIdentityDiagnosticCodes{}, 0, sequencePayloadRecord) + if len(priorIssues) != 0 || len(laterIssues) != 0 { + return result + } + result.eligible = true + result.entryToFinal = make([]int, len(before.Sequence)) + result.finalToEntry = make([]int, len(after.Sequence)) + for i := range result.entryToFinal { + result.entryToFinal[i] = -1 + } + for i := range result.finalToEntry { + result.finalToEntry[i] = -1 + } + result.structural = len(prior) != len(later) + for tuple, previous := range prior { + if current, found := later[tuple]; found { + result.entryToFinal[previous.index] = current.index + result.finalToEntry[current.index] = previous.index + if previous.index != current.index { + result.structural = true + } + } else { + result.structural = true + } + } + return result +} + +// entryPath translates a surviving final record prefix; additions have no entry. +// Paths outside eligible collections and wholly absent final positions stay positional. +func (c resolutionCorrespondence) entryPath(path Path) (Path, bool) { + if !path.valid() { + return Path{}, false + } + for depth := 0; depth < len(path.segments); depth++ { + prefix := Path{segments: path.segments[:depth]} + collection, found := c.collections[prefix.String()] + if !found || !collection.eligible { + continue + } + compatible := true + for i, segment := range collection.path.segments { + if segment.kind == pathSegmentKey && path.segments[i].kind == pathSegmentIndex { + compatible = false + break + } + } + index, ok := schemaIndex(path.segments[depth]) + if !compatible || !ok || index >= len(collection.finalToEntry) { + continue + } + previous := collection.finalToEntry[index] + if previous < 0 { + return Path{}, false + } + translated := path.clone() + translated.segments[depth] = pathSegment{kind: pathSegmentIndex, index: previous} + return translated, true + } + return path.clone(), true +} diff --git a/internal/engine/resolution_correspondence_test.go b/internal/engine/resolution_correspondence_test.go new file mode 100644 index 0000000..80bfcbd --- /dev/null +++ b/internal/engine/resolution_correspondence_test.go @@ -0,0 +1,65 @@ +package engine + +import "testing" + +func TestResolutionCollectionCorrespondence(t *testing.T) { + policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{ + selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}, + }}) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + for _, test := range []struct { + name, before, after string + eligible, structural bool + finalIndex, entryIndex int + matched bool + }{ + {"swap", "items: [{id: a}, {id: b}]", "items: [{id: b}, {id: a}]", true, true, 0, 1, true}, + {"insert", "items: [{id: a}]", "items: [{id: b}, {id: a}]", true, true, 0, 0, false}, + {"remove", "items: [{id: a}, {id: b}]", "items: [{id: b}]", true, true, 0, 1, true}, + {"same", "items: [{id: a}]", "items: [{id: a}]", true, false, 0, 0, true}, + {"duplicate", "items: [{id: a}, {id: a}]", "items: [{id: a}]", false, false, 0, 0, true}, + {"missing", "items: [{name: a}]", "items: [{id: a}]", false, false, 0, 0, true}, + {"identity replacement", "items: [{id: a}]", "items: [{id: b}]", true, true, 0, 0, false}, + {"invalid final", "items: [{id: a}]", "items: [bad]", false, false, 0, 0, true}, + } { + t.Run(test.name, func(t *testing.T) { + before := parseCompositionDocument(t, "before", test.before) + after := parseCompositionDocument(t, "after", test.after) + matching := buildResolutionCorrespondence(before, after, policy) + collection := matching.collections["/items"] + if collection.eligible != test.eligible || collection.structural != test.structural { + t.Fatalf("collection = %#v", collection) + } + entry, ok := matching.entryPath(Keys("items").Index(test.finalIndex).Key("value")) + if ok != test.matched || (ok && entry.String() != Keys("items").Index(test.entryIndex).Key("value").String()) { + t.Fatalf("entry = %s, matched = %t", entry.String(), ok) + } + }) + } +} + +func TestResolutionCorrespondenceDynamicCompoundIdentities(t *testing.T) { + policy, diagnostics := compileSequenceIdentityPolicy([]sequenceIdentityRegistration{{ + selector: sequenceIdentitySelector{literalSequenceSelector("groups"), anyMappingSequenceSelector(), literalSequenceSelector("items")}, + identityFields: []string{"id", "kind"}, + }}) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + before := parseCompositionDocument(t, "before", "groups: {alpha: {items: [{id: 1, kind: x}, {id: '1', kind: x}]}, empty: {items: []}}") + after := parseCompositionDocument(t, "after", "groups: {alpha: {items: [{id: '1', kind: x}, {id: 1.0, kind: x}]}, empty: {items: []}}") + matching := buildResolutionCorrespondence(before, after, policy) + if empty := matching.collections["/groups/empty/items"]; !empty.eligible || empty.structural { + t.Fatalf("empty = %#v", empty) + } + path, ok := matching.entryPath(Keys("groups", "alpha", "items").Index(0).Key("missing")) + if !ok || path.String() != "/groups/alpha/items/1/missing" { + t.Fatalf("entry = %s, %t", path.String(), ok) + } + wrongShape, ok := matching.entryPath(Keys("groups").Index(0).Key("items").Index(0)) + if !ok || wrongShape.String() != "/groups/0/items/0" { + t.Fatalf("mapping shape = %s, %t", wrongShape.String(), ok) + } +}