Compose registered sequences by identity

This commit is contained in:
2026-10-16 15:25:06 +00:00
parent ff212885c4
commit dc7a70811a
3 changed files with 365 additions and 25 deletions

View File

@@ -256,6 +256,22 @@ Scope: `internal/engine/compose.go` and focused composition tests.
Validation: composition tests and `./scripts/check.sh`. Completion: the private Validation: composition tests and `./scripts/check.sh`. Completion: the private
composer returns the correct tree and range projection without aliasing inputs. composer returns the correct tree and range projection without aliasing inputs.
Completion evidence: added a policy-aware private composer while retaining the
unregistered adapter. Registered sequences validate every actual contribution,
including singleton and empty lists; distinct records append in source/list
order, duplicate exact string identities fail, and other overlap kinds retain
strict-conflict behavior. The result carries per-source sequence ranges for
later projection. Tests cover malformed identity records, within/across-list
duplicates including empty IDs and differing bodies, absent/non-sequence paths,
empty lists, unregistered conflicts, and input/result ownership. These checks
passed with Go `go1.26.9 linux/amd64`:
- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'Compose' -count=1`
- `GOTOOLCHAIN=local ./scripts/check.sh`
- `git diff --check`
No environmental limitation affected validation; checks ran on Linux/amd64.
## Stage 3 — Project composition origins and claims onto appended indexes ## Stage 3 — Project composition origins and claims onto appended indexes
Scope: composition evidence, not import acquisition. Scope: composition evidence, not import acquisition.

View File

@@ -6,13 +6,14 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// precedence layers. Claims remain ordered by source and declaration traversal; // precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping. // origins retain every source that contributes to a composite mapping.
type composedDocument struct { type composedDocument struct {
root *document.Node root *document.Node
claims []compositionClaim claims []compositionClaim
origins map[string]Origin sequenceRanges []sequenceRange
overlays []overlayRecord origins map[string]Origin
applications []historyApplication overlays []overlayRecord
inputBytes int64 applications []historyApplication
inputNodes int inputBytes int64
inputNodes int
} }
// assemblyAccumulator owns one mutable working document. Acquired trees held by // assemblyAccumulator owns one mutable working document. Acquired trees held by
@@ -31,16 +32,18 @@ func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
func cloneComposedDocument(base *composedDocument) *composedDocument { func cloneComposedDocument(base *composedDocument) *composedDocument {
return &composedDocument{ return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications), applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes, inputBytes: base.inputBytes, inputNodes: base.inputNodes,
} }
} }
type compositionClaim struct { type compositionClaim struct {
path Path path Path
kind document.Kind kind document.Kind
location Location location Location
sourceIndex int
} }
type compositionClaims struct { type compositionClaims struct {
@@ -48,16 +51,32 @@ type compositionClaims struct {
byPath map[string][]compositionClaim byPath map[string][]compositionClaim
} }
// sequenceRange records where one source's sequence items were assigned in the
// composed sequence at path. End is exclusive.
type sequenceRange struct {
sourceIndex int
path Path
start int
end int
}
// composeDocuments recursively joins disjoint mapping entries. Source order // composeDocuments recursively joins disjoint mapping entries. Source order
// controls only output traversal and diagnostics: every non-mapping overlap is // controls only output traversal and diagnostics: every non-mapping overlap is
// rejected before a result is assembled. // rejected before a result is assembled.
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) { func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
}
// composeDocumentsWithPolicy composes one strict tier using the registered
// sequence rules when policy contains registrations. The ordinary adapter above
// deliberately preserves the existing unregistered behavior for current callers.
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 { if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)} return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
} }
collected := compositionClaims{byPath: make(map[string][]compositionClaim)} collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
for _, input := range inputs { for sourceIndex, input := range inputs {
if input == nil || input.Kind != document.Mapping { if input == nil || input.Kind != document.Mapping {
var location *Location var location *Location
if input != nil { if input != nil {
@@ -66,7 +85,7 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
} }
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)} return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
} }
collected.append(input, Path{}) collected.append(input, Path{}, sourceIndex)
} }
checked := make(map[string]struct{}, len(collected.byPath)) checked := make(map[string]struct{}, len(collected.byPath))
@@ -77,16 +96,21 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
} }
checked[pointer] = struct{}{} checked[pointer] = struct{}{}
claims := collected.byPath[pointer] claims := collected.byPath[pointer]
if len(claims) < 2 || allMappingClaims(claims) { if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
continue continue
} }
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)} return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
} }
validated, ranges, diagnostics := validateRegisteredSequences(inputs, collected.ordered, policy)
if len(diagnostics) != 0 {
return nil, diagnostics
}
root := document.Clone(inputs[0]) root := document.Clone(inputs[0])
index := new(mappingIndex) index := new(mappingIndex)
for _, input := range inputs[1:] { for sourceIndex, input := range inputs[1:] {
mergeDisjointMappings(root, input, index) mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
} }
applications := make([]historyApplication, 0, len(inputs)) applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs { for _, input := range inputs {
@@ -95,10 +119,11 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
origins := compositionOrigins(collected.ordered) origins := compositionOrigins(collected.ordered)
completeCompositionOrigins(root, Path{}, origins) completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{ return &composedDocument{
root: root, root: root,
claims: cloneCompositionClaims(collected.ordered), claims: cloneCompositionClaims(collected.ordered),
origins: origins, sequenceRanges: cloneSequenceRanges(ranges),
applications: applications, origins: origins,
applications: applications,
}, nil }, nil
} }
@@ -121,11 +146,12 @@ func completeCompositionOrigins(node *document.Node, path Path, origins map[stri
} }
} }
func (c *compositionClaims) append(node *document.Node, path Path) { func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
claim := compositionClaim{ claim := compositionClaim{
path: path.clone(), path: path.clone(),
kind: node.Kind, kind: node.Kind,
location: publicLocation(node.Location), location: publicLocation(node.Location),
sourceIndex: sourceIndex,
} }
c.ordered = append(c.ordered, claim) c.ordered = append(c.ordered, claim)
pointer := path.String() pointer := path.String()
@@ -135,10 +161,149 @@ func (c *compositionClaims) append(node *document.Node, path Path) {
return return
} }
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
c.append(entry.Value, path.Key(entry.Key)) c.append(entry.Value, path.Key(entry.Key), sourceIndex)
} }
} }
func allSequenceClaims(claims []compositionClaim) bool {
for _, claim := range claims {
if claim.kind != document.Sequence {
return false
}
}
return true
}
type validatedSequence struct {
items []*document.Node
}
func validateRegisteredSequences(inputs []*document.Node, claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
itemsByPath := make(map[string]map[int]validatedSequence)
identitiesByPath := make(map[string]map[string]Location)
lengthsByPath := make(map[string]int)
var ranges []sequenceRange
for _, claim := range claims {
if claim.kind != document.Sequence {
continue
}
identityField, registered := policy.identityFields[claim.path.String()]
if !registered {
continue
}
node, ok := documentAtPath(inputs[claim.sourceIndex], claim.path)
if !ok || node.Kind != document.Sequence {
continue
}
pathKey := claim.path.String()
identities := identitiesByPath[pathKey]
if identities == nil {
identities = make(map[string]Location, len(node.Sequence))
identitiesByPath[pathKey] = identities
}
start := lengthsByPath[pathKey]
contribution := validatedSequence{items: node.Sequence}
for itemIndex, item := range node.Sequence {
itemPath := claim.path.Index(itemIndex)
if item == nil || item.Kind != document.Mapping {
location := (*Location)(nil)
if item != nil {
converted := publicLocation(item.Location)
location = &converted
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
}}
}
fieldIndex := documentEntryIndex(item.Mapping, identityField)
if fieldIndex < 0 {
location := publicLocation(item.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence item is missing its identity field",
Primary: &location,
}}
}
field := item.Mapping[fieldIndex].Value
if field == nil || field.Kind != document.String {
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
if field != nil {
location = publicLocation(field.Location)
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath.Key(identityField), Message: "registered sequence identity must be a string",
Primary: &location,
}}
}
if first, duplicate := identities[field.Text]; duplicate {
current := publicLocation(field.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeConflict, Severity: SeverityError,
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
Primary: &first, Related: []Location{current},
}}
}
identities[field.Text] = publicLocation(field.Location)
}
if itemsByPath[pathKey] == nil {
itemsByPath[pathKey] = make(map[int]validatedSequence)
}
itemsByPath[pathKey][claim.sourceIndex] = contribution
lengthsByPath[pathKey] = start + len(node.Sequence)
ranges = append(ranges, sequenceRange{
sourceIndex: claim.sourceIndex, path: claim.path.clone(), start: start, end: start + len(node.Sequence),
})
}
return itemsByPath, ranges, nil
}
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
for _, incomingEntry := range incoming.Mapping {
entryPath := path.Key(incomingEntry.Key)
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
index.append(target, cloned)
continue
}
current := target.Mapping[position].Value
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
contribution := validated[entryPath.String()][sourceIndex]
if contribution.items != nil {
for _, item := range contribution.items {
current.Sequence = append(current.Sequence, document.Clone(item))
}
}
continue
}
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
}
}
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
return
}
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
}
func (p sequencePolicy) registered(path Path) bool {
_, ok := p.identityFields[path.String()]
return ok
}
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
cloned := make([]sequenceRange, len(ranges))
for i, assigned := range ranges {
cloned[i] = assigned
cloned[i].path = assigned.path.clone()
}
return cloned
}
func allMappingClaims(claims []compositionClaim) bool { func allMappingClaims(claims []compositionClaim) bool {
for _, claim := range claims { for _, claim := range claims {
if claim.kind != document.Mapping { if claim.kind != document.Mapping {

View File

@@ -2,6 +2,7 @@ package engine
import ( import (
"reflect" "reflect"
"strings"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -190,6 +191,164 @@ func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
} }
} }
func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
}
records, ok := documentAtPath(composed.root, Keys("records"))
if !ok || records.Kind != document.Sequence || len(records.Sequence) != 3 {
t.Fatalf("records = %#v, want three appended records", records)
}
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
{sourceIndex: 0, path: Keys("records"), start: 0, end: 2},
{sourceIndex: 1, path: Keys("records"), start: 2, end: 3},
}) {
t.Fatalf("sequence ranges = %#v", got)
}
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
t.Fatal("registered composition mutated an input")
}
composed.root.Mapping[0].Value.Sequence[0].Mapping[1].Value.Text = "changed"
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
t.Fatal("composed sequence aliases an input")
}
}
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
tests := []struct {
name string
body string
path string
wantCode Code
}{
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid},
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", test.body),
)
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v, want %s at %s", diagnostics, test.wantCode, test.path)
}
if diagnostics[0].Primary == nil {
t.Fatal("malformed sequence diagnostic has no source location")
}
})
}
}
func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
tests := []struct {
name string
first string
second string
within bool
}{
{name: "within identical bodies", first: "records: [{id: '', value: same}, {id: '', value: same}]\n", within: true},
{name: "within disjoint bodies", first: "records: [{id: same, left: 1}, {id: same, right: 2}]\n", within: true},
{name: "across identical bodies", first: "records: [{id: same, value: same}]\n", second: "records: [{id: same, value: same}]\n"},
{name: "across disjoint bodies", first: "records: [{id: same, left: 1}]\n", second: "records: [{id: same, right: 2}]\n"},
{name: "empty identity across lists", first: "records: [{id: ''}]\n", second: "records: [{id: ''}]\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
inputs := []*document.Node{parseCompositionDocument(t, "source-1", test.first)}
if !test.within {
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
}
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
}
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("duplicate locations = %#v", diagnostics[0])
}
for _, secret := range []string{"same", "left", "right", "value"} {
if strings.Contains(diagnostics[0].Message, secret) {
t.Fatalf("diagnostic leaks %q: %q", secret, diagnostics[0].Message)
}
}
})
}
}
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
}}
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
not-a-sequence: null
catalog:
records: [{id: present}]
`))
if len(diagnostics) != 0 {
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
}
if len(composed.sequenceRanges) != 1 || composed.sequenceRanges[0].path.String() != "/catalog/records" {
t.Fatalf("sequence ranges = %#v, want only the reached descendant sequence", composed.sequenceRanges)
}
composed, diagnostics = composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", "records: []\n"),
parseCompositionDocument(t, "source-2", "records: []\n"),
)
if len(diagnostics) != 0 || len(composed.root.Mapping[0].Value.Sequence) != 0 {
t.Fatalf("empty registered sequences = (%#v, %#v)", composed, diagnostics)
}
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
{sourceIndex: 0, path: Keys("records"), start: 0, end: 0},
{sourceIndex: 1, path: Keys("records"), start: 0, end: 0},
}) {
t.Fatalf("empty sequence ranges = %#v", got)
}
}
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
)
assertCompositionConflict(t, diagnostics, "/unregistered", []string{"source-1", "source-2"})
}
func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testing.T) {
tests := []struct {
name string
first string
second string
}{
{name: "mapping and sequence", first: "records: {id: one}\n", second: "records: [{id: two}]\n"},
{name: "null and sequence", first: "records: null\n", second: "records: [{id: two}]\n"},
{name: "scalar and sequence", first: "records: value\n", second: "records: [{id: two}]\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", test.first),
parseCompositionDocument(t, "source-2", test.second),
)
assertCompositionConflict(t, diagnostics, "/records", []string{"source-1", "source-2"})
})
}
}
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node { func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
t.Helper() t.Helper()
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits()) parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())