diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 0a12d31..7f3f81d 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–7 complete. +Status: implementation in progress; Stages 1–8 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -359,6 +359,10 @@ Scope: use the prepared item mappings consistently in metadata consumers. Validation: metadata/history/sequence/provenance/sensitivity tests, `go test -race ./internal/engine`, and the everyday check. +**Completion:** Implemented and verified with focused projection, history, +sequence, provenance, and sensitivity tests, `go test -race ./internal/engine`, +and `./scripts/check.sh`. + ## Stage 9 — Separate Per-Input Acquisition From Composition Scope: a behavior-preserving acquired-input handoff; grouped underlay coordination diff --git a/internal/engine/compose.go b/internal/engine/compose.go index be24206..5fc379f 100644 --- a/internal/engine/compose.go +++ b/internal/engine/compose.go @@ -83,6 +83,48 @@ type sequenceRange struct { path Path start int end int + items []sequenceItemProjection + projection sequenceItemProjectionIndex +} + +func (assigned sequenceRange) projections() []sequenceItemProjection { + if assigned.items != nil { + return append([]sequenceItemProjection{}, assigned.items...) + } + return sequenceItemProjections(assigned.end-assigned.start, assigned.start) +} + +func (assigned sequenceRange) outputForInput(inputIndex int) (int, bool) { + if assigned.projection.outputByInput != nil { + return assigned.projection.outputForInput(inputIndex) + } + if assigned.items != nil { + return newSequenceItemProjectionIndex(assigned.items).outputForInput(inputIndex) + } + if inputIndex < 0 || inputIndex >= assigned.end-assigned.start { + return 0, false + } + return assigned.start + inputIndex, true +} + +func (assigned sequenceRange) inputForOutput(outputIndex int) (int, bool) { + if assigned.projection.inputByOutput != nil { + return assigned.projection.inputForOutput(outputIndex) + } + if assigned.items != nil { + return newSequenceItemProjectionIndex(assigned.items).inputForOutput(outputIndex) + } + if outputIndex < assigned.start || outputIndex >= assigned.end { + return 0, false + } + return outputIndex - assigned.start, true +} + +func indexSequenceRange(assigned sequenceRange) sequenceRange { + if assigned.projection.outputByInput == nil && assigned.projection.inputByOutput == nil { + assigned.projection = newSequenceItemProjectionIndex(assigned.projections()) + } + return assigned } // composeDocuments recursively joins disjoint mapping entries. Source order @@ -371,6 +413,11 @@ func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange { for i, assigned := range ranges { cloned[i] = assigned cloned[i].path = assigned.path.clone() + if assigned.items != nil { + cloned[i].items = append([]sequenceItemProjection{}, assigned.items...) + } + cloned[i].projection.outputByInput = append([]int(nil), assigned.projection.outputByInput...) + cloned[i].projection.inputByOutput = append([]int(nil), assigned.projection.inputByOutput...) } return cloned } @@ -410,12 +457,22 @@ func documentEntryIndex(entries []document.Entry, key string) int { func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin { origins := make(map[string]Origin) - rangeBySourcePath := make(map[sequenceRangeKey]int, len(ranges)) - for index, assigned := range ranges { - rangeBySourcePath[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = index + sequencesBySource := make([][]sequenceHistoryContribution, len(inputs)) + for _, assigned := range ranges { + sequencesBySource[assigned.sourceIndex] = append(sequencesBySource[assigned.sourceIndex], sequenceHistoryContribution{ + path: assigned.path.clone(), mode: sequenceHistoryMapped, items: assigned.projections(), + }) } for sourceIndex, input := range inputs { - collectProjectedCompositionOrigins(input, Path{}, Path{}, sourceIndex, ranges, rangeBySourcePath, origins) + application := historyApplication{value: input, sequences: sequencesBySource[sourceIndex]} + application.sequencesByPath = indexSequenceHistoryContributions(application.sequences) + walkHistoryApplication(application, func(_, effective Path, node *document.Node) { + pointer := effective.String() + origin := origins[pointer] + origin.Layer = LayerBase + origin.Locations = append(origin.Locations, publicLocation(node.Location)) + origins[pointer] = origin + }) } return origins } @@ -433,39 +490,12 @@ func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex { } index := make(sequenceRangeIndex, len(ranges)) for _, assigned := range ranges { + assigned = indexSequenceRange(assigned) index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned } return index } -// Keep local paths for matching Stage 2 ranges and effective paths for emitted -// evidence. The paths are transformed segment by segment; pointer strings are -// only used as map keys after projection is complete. -func collectProjectedCompositionOrigins(node *document.Node, localPath, effectivePath Path, sourceIndex int, ranges []sequenceRange, rangeBySourcePath map[sequenceRangeKey]int, origins map[string]Origin) { - pointer := effectivePath.String() - origin := origins[pointer] - origin.Layer = LayerBase - origin.Locations = append(origin.Locations, publicLocation(node.Location)) - origins[pointer] = origin - - switch node.Kind { - case document.Mapping: - for _, entry := range node.Mapping { - collectProjectedCompositionOrigins(entry.Value, localPath.Key(entry.Key), effectivePath.Key(entry.Key), sourceIndex, ranges, rangeBySourcePath, origins) - } - case document.Sequence: - start := 0 - if rangeIndex, ok := rangeBySourcePath[sequenceRangeKey{sourceIndex: sourceIndex, path: localPath.String()}]; ok { - start = ranges[rangeIndex].start - } - for index, child := range node.Sequence { - localChild := localPath.Index(index) - effectiveChild := effectivePath.Index(start + index) - collectProjectedCompositionOrigins(child, localChild, effectiveChild, sourceIndex, ranges, rangeBySourcePath, origins) - } - } -} - func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) { for pointer, origin := range origins { if _, present := decoded.presence[pointer]; present { diff --git a/internal/engine/compose_test.go b/internal/engine/compose_test.go index ec9aba7..0ac0788 100644 --- a/internal/engine/compose_test.go +++ b/internal/engine/compose_test.go @@ -357,6 +357,78 @@ escaped/~: } } +func TestProjectedCompositionOriginsUseNoncontiguousItemMappings(t *testing.T) { + lowerRoot := parseCompositionDocument(t, "lower", `records: + - id: first + label: equal + - id: middle + - id: last + nested: {old: lower} +`) + higherRoot := parseCompositionDocument(t, "higher", `records: + - id: last + nested: {new: higher} + - id: first + label: equal + - id: added +`) + lower, _ := documentAtPath(lowerRoot, Keys("records")) + higher, _ := documentAtPath(higherRoot, Keys("records")) + combined, diagnostics := combineSequence( + lower, higher, sequenceCombinationPolicy(sequenceModeUpsert, "id"), + sequenceCombinationContext{destination: Keys("records")}, + ) + if len(diagnostics) != 0 { + t.Fatalf("combine diagnostics = %#v", diagnostics) + } + inputs := []*document.Node{lowerRoot, higherRoot} + ranges := []sequenceRange{ + { + sourceIndex: 0, path: Keys("records"), start: 0, end: 3, + items: combined.lowerItems, + }, { + sourceIndex: 1, path: Keys("records"), start: 0, end: 3, + items: combined.higherItems, + }, + } + origins := projectedCompositionOrigins(inputs, ranges) + if got, want := sequenceIdentityValues(combined.value, "id"), []string{"first", "middle", "last", "added"}; !reflect.DeepEqual(got, want) { + t.Fatalf("combined IDs = %#v, want %#v", got, want) + } + for path, want := range map[string][]string{ + "/records/0/label": {"higher"}, + "/records/1/id": {"lower"}, + "/records/2/nested/new": {"higher"}, + "/records/3/id": {"higher"}, + "/records/0/nested/new": nil, + "/records/2/nested/old": nil, + } { + origin := origins[path] + var got []string + for _, location := range origin.Locations { + got = append(got, location.SourceID) + } + if !reflect.DeepEqual(got, want) { + t.Errorf("origin sources at %s = %#v, want %#v", path, got, want) + } + } +} + +func TestCloneSequenceRangesPreservesPreparedProjection(t *testing.T) { + ranges := []sequenceRange{{ + start: 0, end: 1, + projection: newSequenceItemProjectionIndex([]sequenceItemProjection{{inputIndex: 0, outputIndex: 4}}), + }} + cloned := cloneSequenceRanges(ranges) + if output, ok := cloned[0].outputForInput(0); !ok || output != 4 { + t.Fatalf("cloned projection = %d, %v; want output index 4", output, ok) + } + cloned[0].projection.outputByInput[0] = 9 + if output, ok := ranges[0].outputForInput(0); !ok || output != 4 { + t.Fatalf("cloned projection aliases original: %d, %v", output, ok) + } +} + func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/directives.go b/internal/engine/directives.go index 44ae22b..df34ee7 100644 --- a/internal/engine/directives.go +++ b/internal/engine/directives.go @@ -165,10 +165,11 @@ func fragmentVersionClaimWithIndex(root *document.Node, rule *VersionRule, sourc return current } if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: currentPath.String()}]; found { - if index < assigned.start || index >= assigned.end { + projected, ok := assigned.inputForOutput(index) + if !ok { return nil } - index -= assigned.start + index = projected } if index < 0 || index >= len(current.Sequence) { return nil diff --git a/internal/engine/history.go b/internal/engine/history.go index 7ea9362..b8f45ec 100644 --- a/internal/engine/history.go +++ b/internal/engine/history.go @@ -273,8 +273,7 @@ func sequenceHistoryAt(contributions map[string]sequenceHistoryContributionIndex type sequenceHistoryContributionIndex struct { sequenceHistoryContribution - outputIndexes []int - inputIndexes []int + projection sequenceItemProjectionIndex replacedOutputs map[int]struct{} } @@ -286,26 +285,7 @@ func indexSequenceHistoryContributions(values []sequenceHistoryContribution) map for _, value := range values { contribution := sequenceHistoryContributionIndex{sequenceHistoryContribution: value} if value.mode == sequenceHistoryMapped { - maxOutput := -1 - for _, projection := range value.items { - if projection.outputIndex > maxOutput { - maxOutput = projection.outputIndex - } - } - contribution.outputIndexes = make([]int, len(value.items)) - for inputIndex := range contribution.outputIndexes { - contribution.outputIndexes[inputIndex] = -1 - } - contribution.inputIndexes = make([]int, maxOutput+1) - for outputIndex := range contribution.inputIndexes { - contribution.inputIndexes[outputIndex] = -1 - } - for _, projection := range value.items { - if projection.inputIndex >= 0 && projection.inputIndex < len(contribution.outputIndexes) && projection.outputIndex >= 0 && projection.outputIndex < len(contribution.inputIndexes) { - contribution.outputIndexes[projection.inputIndex] = projection.outputIndex - contribution.inputIndexes[projection.outputIndex] = projection.inputIndex - } - } + contribution.projection = newSequenceItemProjectionIndex(value.items) contribution.replacedOutputs = make(map[int]struct{}, len(value.replacements)) for _, replacement := range value.replacements { contribution.replacedOutputs[replacement.outputIndex] = struct{}{} @@ -317,17 +297,19 @@ func indexSequenceHistoryContributions(values []sequenceHistoryContribution) map } func (contribution sequenceHistoryContributionIndex) outputForInput(inputIndex int) int { - if inputIndex < 0 || inputIndex >= len(contribution.outputIndexes) { + outputIndex, ok := contribution.projection.outputForInput(inputIndex) + if !ok { return -1 } - return contribution.outputIndexes[inputIndex] + return outputIndex } func (contribution sequenceHistoryContributionIndex) inputForOutput(outputIndex int) int { - if outputIndex < 0 || outputIndex >= len(contribution.inputIndexes) { + inputIndex, ok := contribution.projection.inputForOutput(outputIndex) + if !ok { return -1 } - return contribution.inputIndexes[outputIndex] + return inputIndex } func applySequenceHistory(target **document.Node, incoming *document.Node, contribution sequenceHistoryContributionIndex) { diff --git a/internal/engine/history_sequence_test.go b/internal/engine/history_sequence_test.go index 42cdd27..1ad552a 100644 --- a/internal/engine/history_sequence_test.go +++ b/internal/engine/history_sequence_test.go @@ -230,6 +230,77 @@ func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) { } } +func TestMappedUpsertProjectsSensitiveReplacementEvidence(t *testing.T) { + lowerRoot := parseCompositionDocument(t, "lower", `records: + - id: first + - id: middle + - id: last + token: MAPPED-SECRET-CANARY +`) + higherRoot := parseCompositionDocument(t, "higher", `records: + - id: last + token: visible + - id: first + - id: added +`) + lower, _ := documentAtPath(lowerRoot, Keys("records")) + higher, _ := documentAtPath(higherRoot, Keys("records")) + combined, diagnostics := combineSequence( + lower, higher, + sequenceCombinationPolicy(sequenceModeUpsert, "id"), + sequenceCombinationContext{destination: Keys("records")}, + ) + if len(diagnostics) != 0 { + t.Fatalf("combine diagnostics = %#v", diagnostics) + } + secretPath := Keys("records").Index(2).Key("token") + secretNode, present := documentAtPath(lowerRoot, Keys("records").Index(2).Key("token")) + if !present { + t.Fatal("sensitive source token is missing") + } + secretNode.Sensitive = true + application := historyApplication{ + layer: LayerOverlay, value: higherRoot, + sequences: []sequenceHistoryContribution{{ + path: Keys("records"), mode: sequenceHistoryMapped, + items: combined.higherItems, replacements: combined.replacements, + }}, + } + application.sequencesByPath = indexSequenceHistoryContributions(application.sequences) + applications := []historyApplication{{value: lowerRoot}, application} + replayed := &document.Node{Kind: document.Mapping} + for _, current := range applications { + applyHistoryApplication(&replayed, current, new(mappingIndex)) + } + replayedRecords, _ := documentAtPath(replayed, Keys("records")) + if !reflect.DeepEqual(replayedRecords.Sequence, combined.value.Sequence) { + t.Fatalf("replayed value = %#v, want combination = %#v", replayedRecords.Sequence, combined.value.Sequence) + } + finalRoot := document.Clone(lowerRoot) + finalRecords, _ := documentAtPath(finalRoot, Keys("records")) + finalRecords.Sequence = document.Clone(combined.value).Sequence + + var metadata Metadata + finalizeMetadataPaths(&metadata, finalRoot, applications) + inspection := &Inspection{ + metadata: metadata, baseline: &document.Node{Kind: document.Mapping}, + input: finalRoot, final: finalRoot, applications: applications, + } + secretRule := func(candidate Path) bool { return reflect.DeepEqual(candidate, secretPath) } + inspection.secrets = classifySecretPaths(nil, secretRule, metadata, finalRoot, applications) + explanation, ok := inspection.Explain(secretPath) + if !ok || !explanation.Effective.Redacted { + t.Fatalf("mapped replacement explanation = %#v, %v", explanation, ok) + } + var rendered bytes.Buffer + if err := inspection.WriteExplanation(&rendered, secretPath); err != nil { + t.Fatal(err) + } + if strings.Contains(rendered.String(), "MAPPED-SECRET-CANARY") || !strings.Contains(rendered.String(), "[REDACTED]") { + t.Fatalf("mapped replacement history leaked or was not redacted: %s", rendered.String()) + } +} + func TestSequenceHistoryReplaysCombinationEvidence(t *testing.T) { tests := []struct { name string diff --git a/internal/engine/imports.go b/internal/engine/imports.go index df6e38a..414b40c 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -817,8 +817,13 @@ func projectNestedEvidence(composed *composedDocument, scope acquisitionScope) { projectedApplications = append(projectedApplications, applications[sourceIndex]) continue } - start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start - prefix := contribution.destination.Index(start) + itemIndex := 0 + if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found { + if projected, ok := assigned.outputForInput(0); ok { + itemIndex = projected + } + } + prefix := contribution.destination.Index(itemIndex) if sourceIndex < len(applications) { outer := applications[sourceIndex] structural := document.Clone(outer.value) @@ -875,7 +880,12 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc } contributionPrefix := prefix contributionInsideItem := insideItem - start := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}].start + itemIndex := 0 + if assigned, found := ranges[sequenceRangeKey{sourceIndex: sourceIndex, path: contribution.destination.String()}]; found { + if projected, ok := assigned.outputForInput(0); ok { + itemIndex = projected + } + } if contribution.source.placementMode == placementModeSequenceItem { container := appendDestination(contributionPrefix, contribution.destination) contribution.source.Destination = container.clone() @@ -885,7 +895,7 @@ func projectAcquiredDestinations(composed *composedDocument, scope acquisitionSc sources[position].Destination = container sources[position].destinationUnresolved = false } - contributionPrefix = appendDestination(container, Path{}.Index(start)) + contributionPrefix = appendDestination(container, Path{}.Index(itemIndex)) contributionInsideItem = true projectSourceVersionEvidence(contribution.processing, contributionPrefix, nil, sourceIndex) } else if contributionInsideItem { @@ -928,7 +938,9 @@ func projectSourceVersionEvidence(processing *sourceVersionProcessingResult, des } item, ok := schemaIndex(path.segments[position]) if ok && path.segments[position].kind != pathSegmentKey { - path.segments[position] = pathSegment{kind: pathSegmentIndex, index: assigned.start + item} + if outputIndex, projected := assigned.outputForInput(item); projected { + path.segments[position] = pathSegment{kind: pathSegmentIndex, index: outputIndex} + } } } paths[index] = path diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 7cab458..378b99d 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -525,6 +525,82 @@ func TestLoadMainItemPlacementKeepsItsHelpersInTheItem(t *testing.T) { } } +func TestProjectAcquiredDestinationsUsesMappedSequenceItemIndex(t *testing.T) { + child := &composedDocument{} + childScope := acquisitionScope{contributions: []acquiredContribution{{ + source: SourceInfo{ID: "nested-source"}, destination: Keys("settings"), + }}} + child.acquisition = []acquisitionScope{childScope} + composed := &composedDocument{sequenceRanges: []sequenceRange{{ + sourceIndex: 0, path: Keys("records"), start: 0, end: 1, + items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, + }}} + scope := acquisitionScope{contributions: []acquiredContribution{{ + source: SourceInfo{ID: "outer-source", placementMode: placementModeSequenceItem}, + destination: Keys("records"), nested: child, + }}} + sources := []SourceInfo{{ID: "outer-source"}, {ID: "nested-source"}} + + projectAcquiredDestinations(composed, scope, sources) + if got, want := sources[0].Destination.String(), "/records"; got != want { + t.Errorf("outer source destination = %q, want %q", got, want) + } + if got, want := sources[1].Destination.String(), "/records/2/settings"; got != want { + t.Errorf("nested source destination = %q, want %q", got, want) + } +} + +func TestProjectNestedEvidenceUsesMappedSequenceItemIndex(t *testing.T) { + outer := historyApplication{ + value: parseCompositionDocument(t, "outer-source", "records: [{id: outer}]\n"), + sequences: []sequenceHistoryContribution{{ + path: Keys("records"), mode: sequenceHistoryMapped, + items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, + }}, + } + outer.sequencesByPath = indexSequenceHistoryContributions(outer.sequences) + nestedRoot := parseCompositionDocument(t, "nested-source", "settings: {value: nested}\n") + nestedOrigin := Origin{Layer: LayerBase, Locations: []Location{{SourceID: "nested-source"}}} + nested := &composedDocument{ + root: nestedRoot, applications: []historyApplication{{value: nestedRoot}}, + origins: map[string]Origin{"/settings/value": nestedOrigin}, + } + scope := acquisitionScope{contributions: []acquiredContribution{{ + source: SourceInfo{ID: "outer-source"}, destination: Keys("records"), nested: nested, + }}} + composed := &composedDocument{ + applications: []historyApplication{outer}, + sequenceRanges: []sequenceRange{{ + sourceIndex: 0, path: Keys("records"), start: 0, end: 1, + items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, + }}, + origins: make(map[string]Origin), + } + + projectNestedEvidence(composed, scope) + if got, want := composed.applications[1].path.String(), "/records/2"; got != want { + t.Fatalf("nested history application path = %q, want %q", got, want) + } + if origin := composed.origins["/records/2/settings/value"]; len(origin.Locations) != 1 || origin.Locations[0].SourceID != "nested-source" { + t.Fatalf("nested origin = %#v", origin) + } + query := Keys("records").Index(2).Key("settings").Key("value") + if operation, relevant := applicationOperationPath(composed.applications[1], query, nil); !relevant || operation.String() != query.String() { + t.Fatalf("nested historical query = %s, %v", operation.String(), relevant) + } + replayed := parseCompositionDocument(t, "lower", "records: [{id: first}, {id: middle}, {id: last}]\n") + for _, application := range composed.applications { + applyHistoryApplication(&replayed, application, new(mappingIndex)) + } + if value, present := documentAtPath(replayed, query); !present || value.Text != "nested" { + t.Fatalf("replayed nested value = %#v, %v", value, present) + } + composed.applications[1].path.segments[0].key = "detached" + if got := nested.applications[0].path.String(); got != "" { + t.Fatalf("projected nested history path aliases its source: %q", got) + } +} + func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T) { t.Parallel() type config struct { diff --git a/internal/engine/sequence_projection.go b/internal/engine/sequence_projection.go new file mode 100644 index 0000000..ecb6827 --- /dev/null +++ b/internal/engine/sequence_projection.go @@ -0,0 +1,50 @@ +package engine + +type sequenceItemProjectionIndex struct { + outputByInput []int + inputByOutput []int +} + +func newSequenceItemProjectionIndex(projections []sequenceItemProjection) sequenceItemProjectionIndex { + maxInput := -1 + maxOutput := -1 + for _, projection := range projections { + if projection.inputIndex > maxInput { + maxInput = projection.inputIndex + } + if projection.outputIndex > maxOutput { + maxOutput = projection.outputIndex + } + } + index := sequenceItemProjectionIndex{ + outputByInput: make([]int, maxInput+1), + inputByOutput: make([]int, maxOutput+1), + } + for input := range index.outputByInput { + index.outputByInput[input] = -1 + } + for output := range index.inputByOutput { + index.inputByOutput[output] = -1 + } + for _, projection := range projections { + if projection.inputIndex >= 0 && projection.inputIndex < len(index.outputByInput) && projection.outputIndex >= 0 && projection.outputIndex < len(index.inputByOutput) { + index.outputByInput[projection.inputIndex] = projection.outputIndex + index.inputByOutput[projection.outputIndex] = projection.inputIndex + } + } + return index +} + +func (index sequenceItemProjectionIndex) outputForInput(inputIndex int) (int, bool) { + if inputIndex < 0 || inputIndex >= len(index.outputByInput) || index.outputByInput[inputIndex] < 0 { + return 0, false + } + return index.outputByInput[inputIndex], true +} + +func (index sequenceItemProjectionIndex) inputForOutput(outputIndex int) (int, bool) { + if outputIndex < 0 || outputIndex >= len(index.inputByOutput) || index.inputByOutput[outputIndex] < 0 { + return 0, false + } + return index.inputByOutput[outputIndex], true +} diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index dde6f5f..f83a646 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -48,6 +48,16 @@ func TestFragmentVersionClaimUsesAssignedSequenceProjection(t *testing.T) { if node := fragmentVersionClaimProjected(fragment, &VersionRule{Path: Keys("items").Index(1).Key("version")}, 1, rangeForPeer); node == nil || node.Text != "v1" { t.Fatalf("assigned peer index 1 claim = %#v, want its version node", node) } + mappedRange := []sequenceRange{{ + sourceIndex: 1, path: Keys("items"), start: 0, end: 3, + items: []sequenceItemProjection{{inputIndex: 0, outputIndex: 2}}, + }} + if node := fragmentVersionClaimProjected(fragment, &VersionRule{Path: Keys("items").Index(0).Key("version")}, 1, mappedRange); node != nil { + t.Fatalf("unassigned mapped peer index claimed a version: %#v", node) + } + if node := fragmentVersionClaimProjected(fragment, &VersionRule{Path: Keys("items").Index(2).Key("version")}, 1, mappedRange); node == nil || node.Text != "v1" { + t.Fatalf("mapped peer index 2 claim = %#v, want its version node", node) + } nested := sourceVersionTestDocument(t, "items: [{nested: [{version: nested-v}]}]\n") nestedRanges := []sequenceRange{ {sourceIndex: 1, path: Keys("items"), start: 1, end: 2},