Resolve collection identity during strict composition

This commit is contained in:
2026-10-23 18:39:04 +00:00
parent 628fc53d2c
commit d03e56398b
5 changed files with 319 additions and 116 deletions

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–10 complete.
Status: implementation in progress; Stages 1–11 complete.
Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit
@@ -440,6 +440,9 @@ Validation: composition and sequence identity tests plus the everyday check.
Keep unregistered non-mapping conflicts unchanged. Public load activation remains
in the later integration stages.
**Completion:** Implemented and verified with focused composition, collection
policy, and sequence identity tests plus `./scripts/check.sh`.
## Stage 12 — Integrate Policy-Aware Item And Helper Scopes
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired

View File

@@ -32,6 +32,22 @@ func resolveCollectionPolicy(
applicationIdentityFields []string,
lower *collectionPolicy,
) (collectionPolicyResolution, []Diagnostic) {
normalizedDeclarations := cloneSequenceDeclarations(declarations)
for index := range normalizedDeclarations {
declaration := &normalizedDeclarations[index]
if !declaration.identityPresent {
continue
}
fields, err := normalizeSequenceIdentityFields(declaration.identityFields)
if err != nil {
return collectionPolicyResolution{}, []Diagnostic{{
Code: CodeSequenceInvalid, Severity: SeverityError, Path: destination.clone(),
Message: "collection identity declaration requires at least one field",
Primary: collectionDeclarationLocation(declaration.identityLocation, declaration.location),
}}
}
declaration.identityFields = fields
}
normalizedApplicationIdentityFields := cloneStrings(applicationIdentityFields)
if len(applicationIdentityFields) != 0 {
var err error
@@ -44,8 +60,8 @@ func resolveCollectionPolicy(
}
}
result := collectionPolicyResolution{
declarations: cloneSequenceDeclarations(declarations),
declarationsPresent: len(declarations) != 0,
declarations: normalizedDeclarations,
declarationsPresent: len(normalizedDeclarations) != 0,
applicationIdentityFields: normalizedApplicationIdentityFields,
applicationIdentityPresent: len(normalizedApplicationIdentityFields) != 0,
}
@@ -55,7 +71,7 @@ func resolveCollectionPolicy(
var declaredIdentityLocation *Location
var declaredMode sequenceMode
var declaredModeLocation *Location
for _, declaration := range declarations {
for _, declaration := range normalizedDeclarations {
if declaration.identityPresent {
result.identityDeclarationPresent = true
if declaredIdentityFields == nil {

View File

@@ -9,6 +9,7 @@ type composedDocument struct {
root *document.Node
acquisition []acquisitionScope
importGraph importInclusionGraph
collectionPolicies collectionPolicyScope
claims []compositionClaim
sequenceRanges []sequenceRange
origins map[string]Origin
@@ -56,6 +57,7 @@ func cloneComposedDocument(base *composedDocument) *composedDocument {
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
acquisition: cloneAcquisitionScopes(base.acquisition),
importGraph: base.importGraph.clone(),
collectionPolicies: cloneCollectionPolicyScope(base.collectionPolicies),
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
@@ -138,6 +140,17 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
// 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) {
return composeDocumentsWithCollectionPolicy(policy, collectionPolicyScope{}, nil, inputs...)
}
// composeDocumentsWithCollectionPolicy resolves collection declarations and
// eligible lower policy before strictly composing completed peer documents.
func composeDocumentsWithCollectionPolicy(
policy sequencePolicy,
lower collectionPolicyScope,
declarations []sequenceDeclarationSpec,
inputs ...*document.Node,
) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
}
@@ -155,6 +168,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
collected.append(input, Path{}, sourceIndex)
}
identityFieldsByPath, collectionPolicies, diagnostics := resolveStrictCollectionPolicies(
collected.ordered, policy, lower, declarations,
)
if len(diagnostics) != 0 {
return nil, diagnostics
}
checked := make(map[string]struct{}, len(collected.byPath))
for _, claim := range collected.ordered {
pointer := claim.path.String()
@@ -163,13 +183,13 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
}
checked[pointer] = struct{}{}
claims := collected.byPath[pointer]
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && len(identityFieldsByPath[pointer]) != 0) {
continue
}
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
}
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, identityFieldsByPath)
if len(diagnostics) != 0 {
return nil, diagnostics
}
@@ -177,7 +197,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
root := document.Clone(inputs[0])
index := new(mappingIndex)
for sourceIndex, input := range inputs[1:] {
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, identityFieldsByPath, validated)
}
applications := make([]historyApplication, 0, len(inputs))
seenSequences := make(map[string]bool)
@@ -205,6 +225,7 @@ func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node)
completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{
root: root,
collectionPolicies: collectionPolicies,
claims: cloneCompositionClaims(collected.ordered),
sequenceRanges: cloneSequenceRanges(ranges),
origins: origins,
@@ -264,106 +285,140 @@ type validatedSequence struct {
items []*document.Node
}
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
func resolveStrictCollectionPolicies(
claims []compositionClaim,
applicationPolicy sequencePolicy,
lower collectionPolicyScope,
declarations []sequenceDeclarationSpec,
) (map[string][]string, collectionPolicyScope, []Diagnostic) {
paths := make([]Path, 0, len(claims)+len(declarations))
seen := make(map[string]bool, len(claims)+len(declarations))
sequencePresent := make(map[string]bool)
declarationsByPath := make(map[string][]sequenceDeclarationSpec, len(declarations))
for _, claim := range claims {
if claim.kind != document.Sequence {
continue
}
key := claim.path.String()
sequencePresent[key] = true
if !seen[key] {
seen[key] = true
paths = append(paths, claim.path.clone())
}
}
for _, declaration := range declarations {
key := declaration.destination.String()
declarationsByPath[key] = append(declarationsByPath[key], declaration)
if !seen[key] {
seen[key] = true
paths = append(paths, declaration.destination.clone())
}
}
identityFieldsByPath := make(map[string][]string)
collectionPolicies := cloneCollectionPolicyScope(lower)
var diagnostics []Diagnostic
for _, path := range paths {
key := path.String()
currentDeclarations := declarationsByPath[key]
lowerEntry, hasLower := lower.collections[key]
var lowerPolicy *collectionPolicy
if hasLower {
copy := cloneCollectionPolicy(lowerEntry.policy)
lowerPolicy = &copy
}
applicationIdentity, _ := applicationPolicy.identityFieldsAt(path)
// A replacing value drops the old YAML identity before this tier's
// peers are checked. The explicit operation itself remains applicable.
selectedMode := sequenceMode(0)
for _, declaration := range currentDeclarations {
if declaration.modePresent {
selectedMode = declaration.mode
break
}
}
if selectedMode == 0 && lowerPolicy != nil && lowerPolicy.explicitModePresent {
selectedMode = lowerPolicy.explicitMode
}
if sequencePresent[key] && selectedMode == sequenceModeReplace && lowerPolicy != nil {
lowerPolicy.identityFields = nil
lowerPolicy.identityPresent = false
lowerPolicy.identityLocation = nil
}
resolution, found := resolveCollectionPolicy(path, currentDeclarations, applicationIdentity, lowerPolicy)
if len(found) != 0 {
diagnostics = append(diagnostics, found...)
continue
}
if len(resolution.effectiveIdentityFields) != 0 {
identityFieldsByPath[key] = cloneStrings(resolution.effectiveIdentityFields)
}
policyForPath := resolution.policy
if hasLower && !sequencePresent[key] {
// A declaration without a new value changes policy metadata but
// leaves retained record ownership untouched.
policyForPath = cloneCollectionPolicy(lowerEntry.policy)
policyForPath.identityFields = cloneStrings(resolution.policy.identityFields)
policyForPath.identityPresent = resolution.policy.identityPresent
policyForPath.identityLocation = cloneLocation(resolution.policy.identityLocation)
policyForPath.explicitMode = resolution.policy.explicitMode
policyForPath.explicitModePresent = resolution.policy.explicitModePresent
policyForPath.modeLocation = cloneLocation(resolution.policy.modeLocation)
}
if collectionPolicies.collections == nil {
collectionPolicies.collections = make(map[string]collectionPolicyEntry)
}
collectionPolicies.collections[key] = collectionPolicyEntry{destination: path.clone(), policy: policyForPath}
}
return identityFieldsByPath, collectionPolicies, canonicalDiagnostics(diagnostics)
}
func validateRegisteredSequences(claims []compositionClaim, identityFieldsByPath map[string][]string) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
itemsByPath := make(map[string]map[int]validatedSequence)
identitiesByPath := make(map[string]map[string]Location)
identitiesByPath := make(map[string]map[string]document.Location)
lengthsByPath := make(map[string]int)
var ranges []sequenceRange
for _, claim := range claims {
if claim.kind != document.Sequence {
continue
}
identityFields, registered := policy.identityFieldsAt(claim.path)
if !registered {
identityFields := identityFieldsByPath[claim.path.String()]
if len(identityFields) == 0 {
continue
}
node := claim.node
pathKey := claim.path.String()
identities := identitiesByPath[pathKey]
if identities == nil {
identities = make(map[string]Location, len(node.Sequence))
identities = make(map[string]document.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(start + itemIndex)
if item == nil || item.Kind != document.Mapping {
location := (*Location)(nil)
if item != nil {
converted := publicLocation(item.Location)
location = &converted
records, diagnostics := validateNormalizedSequenceIdentityFrom(node, claim.path, identityFields, sequenceIdentityDiagnosticCodes{
invalid: CodeComposeInvalid, conflict: CodeComposeConflict,
}, start)
if len(diagnostics) != 0 {
return nil, nil, diagnostics
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
}}
byIndex := make([]sequenceIdentityRecord, len(node.Sequence))
for _, record := range records {
byIndex[record.index] = record
}
var identity string
var identityLocation document.Location
if legacyField, legacy := policy.identityFields[claim.path.String()]; legacy {
fieldIndex := documentEntryIndex(item.Mapping, legacyField)
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(legacyField), Message: "registered sequence identity must be a string",
Primary: &location,
}}
}
identity, identityLocation = field.Text, field.Location
} else {
mapping := newSequenceMappingIndex(item)
var locations []document.Location
var fieldName string
var fieldIndex int
identity, locations, fieldName, fieldIndex = mapping.identity(identityFields)
if fieldIndex == -1 {
location := publicLocation(item.Location)
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: itemPath.Key(fieldName), Message: "registered sequence item is missing an identity field",
Primary: &location,
}}
}
if fieldIndex == -2 || fieldIndex >= 0 {
location := publicLocation(item.Location)
fieldPath := itemPath.Key(fieldName)
if fieldIndex >= 0 {
location = publicLocation(item.Mapping[fieldIndex].ValueLocation)
if field := item.Mapping[fieldIndex].Value; field != nil {
location = publicLocation(field.Location)
}
}
return nil, nil, []Diagnostic{{
Code: CodeComposeInvalid, Severity: SeverityError,
Path: fieldPath, Message: "registered sequence identity fields must be scalar values",
Primary: &location,
}}
}
identityLocation = locations[0]
}
if first, duplicate := identities[identity]; duplicate {
current := publicLocation(identityLocation)
for _, record := range byIndex {
if first, duplicate := identities[record.tuple]; duplicate {
current := publicLocation(record.location)
return nil, nil, []Diagnostic{{
Code: CodeComposeConflict, Severity: SeverityError,
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
Primary: &first, Related: []Location{current},
Primary: ptrLocation(publicLocation(first)), Related: []Location{current},
}}
}
identities[identity] = publicLocation(identityLocation)
identities[record.tuple] = record.location
}
if itemsByPath[pathKey] == nil {
itemsByPath[pathKey] = make(map[int]validatedSequence)
@@ -377,7 +432,7 @@ func validateRegisteredSequences(claims []compositionClaim, policy sequencePolic
return itemsByPath, ranges, nil
}
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
for _, incomingEntry := range incoming.Mapping {
entryPath := path.Key(incomingEntry.Key)
position := index.find(target, incomingEntry.Key)
@@ -388,7 +443,7 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
continue
}
current := target.Mapping[position].Value
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && len(identityFieldsByPath[entryPath.String()]) != 0 {
contribution := validated[entryPath.String()][sourceIndex]
if contribution.items != nil {
for _, item := range contribution.items {
@@ -397,15 +452,15 @@ func mergeComposedMappings(target, incoming *document.Node, path Path, index *ma
}
continue
}
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, identityFieldsByPath, validated)
}
}
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, identityFieldsByPath map[string][]string, validated map[string]map[int]validatedSequence) {
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
return
}
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
mergeComposedMappings(target, incoming, path, index, sourceIndex, identityFieldsByPath, validated)
}
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {

View File

@@ -437,10 +437,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
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},
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -458,14 +455,138 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
}
}
func TestComposeDocumentsUsesLowerIdentityForStrictPeers(t *testing.T) {
path := Keys("records")
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
path.String(): {
destination: path,
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
},
}}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, nil,
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: second}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose strict peers with lower identity: %#v", diagnostics)
}
value, present := documentAtPath(composed.root, path)
if !present || value.Kind != document.Sequence || len(value.Sequence) != 2 {
t.Fatalf("composed records = %#v, %v; want two strict peer records", value, present)
}
resolved, present := composed.collectionPolicies.collections[path.String()]
if !present || !resolved.policy.identityPresent || !reflect.DeepEqual(resolved.policy.identityFields, []string{"id"}) {
t.Fatalf("composed collection policy = %#v, %v; want retained lower identity", resolved, present)
}
clone := cloneComposedDocument(composed)
resolved.policy.identityFields[0] = "mutated"
if !reflect.DeepEqual(clone.collectionPolicies.collections[path.String()].policy.identityFields, []string{"id"}) {
t.Fatal("cloned composition shares mutable collection identity fields")
}
}
func TestComposeDocumentsResolvesDeclarationsBeforePeerComposition(t *testing.T) {
path := Keys("records")
declaration := func(source string, line int, fields ...string) sequenceDeclarationSpec {
location := Location{SourceID: source, Line: line}
return sequenceDeclarationSpec{
destination: path, identityFields: fields, identityPresent: true,
identityLocation: &location,
}
}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "id"), declaration("source-2", 4, "name")},
parseCompositionDocument(t, "source-1", "left: true\n"),
parseCompositionDocument(t, "source-2", "right: true\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeSequenceConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("conflicting policy-only declarations = %#v", diagnostics)
}
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("declaration conflict lacks both locations: %#v", diagnostics[0])
}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "tenant", "id"), declaration("source-2", 4, "id", "tenant")},
parseCompositionDocument(t, "source-1", "records: [{tenant: true, id: null}, {tenant: \"alpha\", id: 1}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: 1.0, tenant: \"beta\"}, {id: \"1\", tenant: \"alpha\"}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("compose normalized compound identities: %#v", diagnostics)
}
value, present := documentAtPath(composed.root, path)
if !present || len(value.Sequence) != 4 {
t.Fatalf("compound identity records = %#v, %v; want four distinct records", value, present)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{}, collectionPolicyScope{},
[]sequenceDeclarationSpec{declaration("source-1", 2, "id")},
parseCompositionDocument(t, "source-1", "records: [{id: 1}]\n"),
parseCompositionDocument(t, "source-2", "records: [{id: 1.0}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("equivalent numeric peer identities = %#v", diagnostics)
}
}
func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T) {
path := Keys("records")
lower := collectionPolicyScope{collections: map[string]collectionPolicyEntry{
path.String(): {
destination: path,
policy: collectionPolicy{identityFields: []string{"id"}, identityPresent: true},
},
}}
declaration := sequenceDeclarationSpec{destination: path, mode: sequenceModeReplace, modePresent: true}
_, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("replacement peers borrowing lower identity = %#v", diagnostics)
}
identityReplacement := sequenceDeclarationSpec{
destination: path, identityFields: []string{"name"}, identityPresent: true,
mode: sequenceModeReplace, modePresent: true,
}
composed, diagnostics := composeDocumentsWithCollectionPolicy(
sequencePolicy{}, lower, []sequenceDeclarationSpec{identityReplacement},
parseCompositionDocument(t, "source-1", "records: [{name: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: second}]\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("replacement with a new identity declaration: %#v", diagnostics)
}
resolved := composed.collectionPolicies.collections[path.String()].policy
if !reflect.DeepEqual(resolved.identityFields, []string{"name"}) || !resolved.identityPresent {
t.Fatalf("replacement collection identity = %#v; want name", resolved)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower,
[]sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/0/id" {
t.Fatalf("replacement ignored application identity constraint = %#v", diagnostics)
}
}
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1/id" {
t.Fatalf("diagnostics = %#v, want invalid identity field at /records/1/id", diagnostics)
}
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)

View File

@@ -214,6 +214,10 @@ func (index sequenceMappingIndex) identity(fields []string) (string, []document.
// validateSequenceIdentity checks mapping shape, direct identity fields, and
// uniqueness in one tuple-to-first-location index. It does not alter the nodes.
func validateSequenceIdentity(sequence *document.Node, path Path, fields []string, codes sequenceIdentityDiagnosticCodes) (map[string]sequenceIdentityRecord, []Diagnostic) {
return validateSequenceIdentityFrom(sequence, path, fields, codes, 0)
}
func validateSequenceIdentityFrom(sequence *document.Node, path Path, fields []string, codes sequenceIdentityDiagnosticCodes, startIndex int) (map[string]sequenceIdentityRecord, []Diagnostic) {
normalized, err := normalizeSequenceIdentityFields(fields)
if err != nil {
location := (*Location)(nil)
@@ -223,6 +227,10 @@ func validateSequenceIdentity(sequence *document.Node, path Path, fields []strin
}
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: path, Message: "sequence identity requires at least one field", Primary: location}}
}
return validateNormalizedSequenceIdentityFrom(sequence, path, normalized, codes, startIndex)
}
func validateNormalizedSequenceIdentityFrom(sequence *document.Node, path Path, normalized []string, codes sequenceIdentityDiagnosticCodes, startIndex int) (map[string]sequenceIdentityRecord, []Diagnostic) {
if sequence == nil || sequence.Kind != document.Sequence {
location := (*Location)(nil)
if sequence != nil {
@@ -234,7 +242,7 @@ func validateSequenceIdentity(sequence *document.Node, path Path, fields []strin
firstByTuple := make(map[string]sequenceIdentityRecord, len(sequence.Sequence))
for itemIndex, item := range sequence.Sequence {
itemPath := path.Index(itemIndex)
itemPath := path.Index(startIndex + itemIndex)
if item == nil || item.Kind != document.Mapping {
var primary *Location
if item != nil {