Add shared sequence identity primitives

This commit is contained in:
2026-10-23 13:49:22 +00:00
parent bc6b977585
commit 64c51647f0
3 changed files with 485 additions and 1 deletions

View File

@@ -0,0 +1,272 @@
package engine
import (
"bytes"
"encoding/binary"
"errors"
"math/big"
"sort"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var (
errEmptySequenceIdentityFields = errors.New("sequence identity requires at least one field")
errInvalidSequenceIdentityNumber = errors.New("invalid sequence identity number")
errUnsupportedSequenceIdentityValue = errors.New("unsupported sequence identity value")
)
type sequenceIdentityDiagnosticCodes struct {
invalid Code
conflict Code
}
type sequenceIdentityRecord struct {
tuple string
index int
location document.Location
}
// normalizeSequenceIdentityFields treats identity field names as a set.
func normalizeSequenceIdentityFields(fields []string) ([]string, error) {
if len(fields) == 0 {
return nil, errEmptySequenceIdentityFields
}
normalized := append([]string(nil), fields...)
sort.Strings(normalized)
write := 0
for _, field := range normalized {
if write != 0 && normalized[write-1] == field {
continue
}
normalized[write] = field
write++
}
return normalized[:write], nil
}
// normalizeSequenceIdentityNumber returns coefficient and exponent without
// expanding powers of ten. The represented value is coefficient * 10^exponent.
func normalizeSequenceIdentityNumber(value string) (string, error) {
if value == "" {
return "", errInvalidSequenceIdentityNumber
}
negative := false
if value[0] == '-' {
negative = true
value = value[1:]
}
if value == "" {
return "", errInvalidSequenceIdentityNumber
}
exponent := new(big.Int)
if index := strings.IndexAny(value, "eE"); index >= 0 {
if strings.IndexAny(value[index+1:], "eE") >= 0 {
return "", errInvalidSequenceIdentityNumber
}
text := value[index+1:]
if text == "" {
return "", errInvalidSequenceIdentityNumber
}
if _, ok := exponent.SetString(text, 10); !ok {
return "", errInvalidSequenceIdentityNumber
}
value = value[:index]
}
point := strings.IndexByte(value, '.')
if point >= 0 && strings.IndexByte(value[point+1:], '.') >= 0 {
return "", errInvalidSequenceIdentityNumber
}
integerPart := value
fractionPart := ""
if point >= 0 {
integerPart = value[:point]
fractionPart = value[point+1:]
}
if integerPart == "" || (point >= 0 && fractionPart == "") {
return "", errInvalidSequenceIdentityNumber
}
if len(integerPart) > 1 && integerPart[0] == '0' {
return "", errInvalidSequenceIdentityNumber
}
digits := integerPart + fractionPart
for _, digit := range digits {
if digit < '0' || digit > '9' {
return "", errInvalidSequenceIdentityNumber
}
}
coefficient := strings.TrimLeft(digits, "0")
if coefficient == "" {
return "0e0", nil
}
exponent.Sub(exponent, big.NewInt(int64(len(fractionPart))))
trimmed := strings.TrimRight(coefficient, "0")
exponent.Add(exponent, big.NewInt(int64(len(coefficient)-len(trimmed))))
coefficient = trimmed
if negative {
coefficient = "-" + coefficient
}
return coefficient + "e" + exponent.String(), nil
}
func sequenceScalarIdentityComponent(node *document.Node) (byte, string, error) {
if node == nil {
return 0, "", errUnsupportedSequenceIdentityValue
}
switch node.Kind {
case document.String:
return 's', node.Text, nil
case document.Number:
normalized, err := normalizeSequenceIdentityNumber(node.Text)
return 'n', normalized, err
case document.Boolean:
if node.Text != "true" && node.Text != "false" {
return 0, "", errUnsupportedSequenceIdentityValue
}
return 'b', node.Text, nil
case document.Null:
return 'z', "", nil
default:
return 0, "", errUnsupportedSequenceIdentityValue
}
}
func sequenceIdentityTuple(values []*document.Node) (string, error) {
var encoded bytes.Buffer
for _, value := range values {
kind, component, err := sequenceScalarIdentityComponent(value)
if err != nil {
return "", err
}
encoded.WriteByte(kind)
var length [binary.MaxVarintLen64]byte
n := binary.PutUvarint(length[:], uint64(len(component)))
encoded.Write(length[:n])
encoded.WriteString(component)
}
return encoded.String(), nil
}
// sequenceMappingIndex is built once per record. Structural fields are omitted
// by entry index, so a directive is excluded only at its actual occurrence.
type sequenceMappingIndex struct {
entries []document.Entry
byKey map[string]int
}
func newSequenceMappingIndex(record *document.Node) sequenceMappingIndex {
index := sequenceMappingIndex{byKey: make(map[string]int)}
if record == nil || record.Kind != document.Mapping {
return index
}
index.entries = record.Mapping
directiveIndex := -1
if directive, exists := inheritanceDirective(record); exists {
for entryIndex := range record.Mapping {
if record.Mapping[entryIndex].Key == directive.Key && record.Mapping[entryIndex].KeyLocation == directive.KeyLocation {
directiveIndex = entryIndex
break
}
}
}
for entryIndex, entry := range record.Mapping {
if entryIndex == directiveIndex {
continue
}
if _, exists := index.byKey[entry.Key]; !exists {
index.byKey[entry.Key] = entryIndex
}
}
return index
}
func (index sequenceMappingIndex) identity(fields []string) (string, []document.Location, string, int) {
values := make([]*document.Node, len(fields))
locations := make([]document.Location, len(fields))
for fieldIndex, field := range fields {
entryIndex, exists := index.byKey[field]
if !exists {
return "", nil, field, -1
}
entry := index.entries[entryIndex]
if entry.Value == nil || (entry.Value.Kind != document.String && entry.Value.Kind != document.Number && entry.Value.Kind != document.Boolean && entry.Value.Kind != document.Null) {
return "", nil, field, entryIndex
}
if _, _, err := sequenceScalarIdentityComponent(entry.Value); err != nil {
return "", nil, field, entryIndex
}
values[fieldIndex] = entry.Value
locations[fieldIndex] = entry.ValueLocation
if entry.Value != nil && entry.Value.Location != (document.Location{}) {
locations[fieldIndex] = entry.Value.Location
}
}
tuple, err := sequenceIdentityTuple(values)
if err != nil {
return "", nil, fields[0], -2
}
return tuple, locations, "", -3
}
// 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) {
normalized, err := normalizeSequenceIdentityFields(fields)
if err != nil {
location := (*Location)(nil)
if sequence != nil {
converted := publicLocation(sequence.Location)
location = &converted
}
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: path, Message: "sequence identity requires at least one field", Primary: location}}
}
if sequence == nil || sequence.Kind != document.Sequence {
location := (*Location)(nil)
if sequence != nil {
converted := publicLocation(sequence.Location)
location = &converted
}
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: path, Message: "identified collection must be a sequence", Primary: location}}
}
firstByTuple := make(map[string]sequenceIdentityRecord, len(sequence.Sequence))
for itemIndex, item := range sequence.Sequence {
itemPath := path.Index(itemIndex)
if item == nil || item.Kind != document.Mapping {
var primary *Location
if item != nil {
converted := publicLocation(item.Location)
primary = &converted
}
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: itemPath, Message: "identified collection items must be mappings", Primary: primary}}
}
mapping := newSequenceMappingIndex(item)
tuple, locations, missing, entryIndex := mapping.identity(normalized)
if entryIndex == -1 {
location := publicLocation(item.Location)
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: itemPath.Key(missing), Message: "identified collection item is missing an identity field", Primary: &location}}
}
if entryIndex == -2 || entryIndex >= 0 {
location := publicLocation(item.Location)
fieldPath := itemPath.Key(missing)
if entryIndex >= 0 {
location = publicLocation(item.Mapping[entryIndex].ValueLocation)
if value := item.Mapping[entryIndex].Value; value != nil {
location = publicLocation(value.Location)
}
}
return nil, []Diagnostic{{Code: codes.invalid, Severity: SeverityError, Path: fieldPath, Message: "identity fields must be scalar values", Primary: &location}}
}
if first, duplicate := firstByTuple[tuple]; duplicate {
current := publicLocation(locations[0])
return nil, []Diagnostic{{Code: codes.conflict, Severity: SeverityError, Path: path, Message: "identified collection contains duplicate identities", Primary: ptrLocation(publicLocation(first.location)), Related: []Location{current}}}
}
firstByTuple[tuple] = sequenceIdentityRecord{tuple: tuple, index: itemIndex, location: locations[0]}
}
return firstByTuple, nil
}
func ptrLocation(location Location) *Location { return &location }

View File

@@ -0,0 +1,209 @@
package engine
import (
"math/big"
"reflect"
"sort"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestNormalizeSequenceIdentityFields(t *testing.T) {
got, err := normalizeSequenceIdentityFields([]string{"name", "id", "id", ""})
if err != nil {
t.Fatal(err)
}
if want := []string{"", "id", "name"}; !reflect.DeepEqual(got, want) {
t.Fatalf("normalized fields = %#v, want %#v", got, want)
}
if _, err := normalizeSequenceIdentityFields(nil); err == nil {
t.Fatal("empty field set unexpectedly succeeded")
}
}
func TestNormalizeSequenceIdentityNumbers(t *testing.T) {
tests := []struct {
left, right string
equal bool
}{
{left: "1", right: "1.0", equal: true},
{left: "1", right: "1e0", equal: true},
{left: "0", right: "-0.000e999", equal: true},
{left: "0.0012300", right: "123e-5", equal: true},
{left: "9007199254740992", right: "9007199254740993", equal: false},
{left: "1.0000000000000000001", right: "1.0000000000000000002", equal: false},
}
for _, test := range tests {
left, leftErr := normalizeSequenceIdentityNumber(test.left)
right, rightErr := normalizeSequenceIdentityNumber(test.right)
if leftErr != nil || rightErr != nil {
t.Fatalf("normalize(%q, %q) errors = %v, %v", test.left, test.right, leftErr, rightErr)
}
if (left == right) != test.equal {
t.Errorf("normalize(%q) = %q; normalize(%q) = %q; equal = %t, want %t", test.left, left, test.right, right, left == right, test.equal)
}
}
}
func TestNormalizeSequenceIdentityNumberLargeExponentWithoutExpansion(t *testing.T) {
exponent := "999999999999999999999999999999999999999999999999999999999999999999999999999999999999"
left, err := normalizeSequenceIdentityNumber("1e" + exponent)
if err != nil {
t.Fatal(err)
}
plusOne, ok := new(big.Int).SetString(exponent, 10)
if !ok {
t.Fatal("test exponent did not parse")
}
plusOne.Add(plusOne, big.NewInt(1))
right, err := normalizeSequenceIdentityNumber("10e" + exponent)
if err != nil {
t.Fatal(err)
}
if want := "1e" + plusOne.String(); right != want {
t.Fatalf("normalized large exponent = %q, want %q", right, want)
}
if left == right {
t.Fatal("unequal large exponents normalized to equal values")
}
}
func TestSequenceIdentityTupleUsesTypeTagsAndLengthBoundaries(t *testing.T) {
tests := []struct {
name string
left []*document.Node
right []*document.Node
equal bool
}{
{name: "empty string differs from null", left: []*document.Node{stringNode("")}, right: []*document.Node{nullNode()}, equal: false},
{name: "empty strings match exactly", left: []*document.Node{stringNode("")}, right: []*document.Node{stringNode("")}, equal: true},
{name: "explicit null matches explicit null", left: []*document.Node{nullNode()}, right: []*document.Node{nullNode()}, equal: true},
{name: "string and number differ", left: []*document.Node{stringNode("1")}, right: []*document.Node{numberNode("1.0")}, equal: false},
{name: "boolean and number differ", left: []*document.Node{booleanNode("true")}, right: []*document.Node{numberNode("1")}, equal: false},
{name: "compound boundaries are encoded", left: []*document.Node{stringNode("ab"), stringNode("c")}, right: []*document.Node{stringNode("a"), stringNode("bc")}, equal: false},
{name: "number spelling is normalized", left: []*document.Node{numberNode("1")}, right: []*document.Node{numberNode("1e0")}, equal: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
left, leftErr := sequenceIdentityTuple(test.left)
right, rightErr := sequenceIdentityTuple(test.right)
if leftErr != nil || rightErr != nil {
t.Fatalf("tuple errors = %v, %v", leftErr, rightErr)
}
if (left == right) != test.equal {
t.Fatalf("tuple equality = %t, want %t", left == right, test.equal)
}
})
}
}
func TestValidateSequenceIdentityUsesOneTupleIndexAndPreservesNodes(t *testing.T) {
first := identityRecord(map[string]*document.Node{
"namespace": stringNode("support"),
"id": numberNode("1.0"),
})
second := identityRecord(map[string]*document.Node{
"id": numberNode("1e0"),
"namespace": stringNode("support"),
})
sequence := &document.Node{Kind: document.Sequence, Location: document.Location{SourceID: "source", Line: 1}, Sequence: []*document.Node{first, second}}
_, diagnostics := validateSequenceIdentity(sequence, Keys("prompts"), []string{"namespace", "id", "id"}, sequenceIdentityDiagnosticCodes{invalid: CodeComposeInvalid, conflict: CodeComposeConflict})
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one duplicate diagnostic", diagnostics)
}
diagnostic := diagnostics[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/prompts" || diagnostic.Primary == nil || len(diagnostic.Related) != 1 {
t.Fatalf("duplicate diagnostic = %#v", diagnostic)
}
if diagnostic.Message == "" || strings.Contains(diagnostic.Message, "support") || strings.Contains(diagnostic.Message, "1.0") {
t.Fatalf("diagnostic unexpectedly contains identity data: %#v", diagnostic)
}
if first.Mapping[0].Value.Text != "1.0" || second.Mapping[0].Value.Text != "1e0" {
t.Fatalf("identity validation rewrote authored values: %q, %q", first.Mapping[0].Value.Text, second.Mapping[0].Value.Text)
}
}
func TestValidateSequenceIdentityReportsMalformedFieldsWithoutValues(t *testing.T) {
tests := []struct {
name string
item *document.Node
wantPath string
}{
{name: "non-mapping item", item: stringNode("private"), wantPath: "/items/0"},
{name: "missing field", item: identityRecord(map[string]*document.Node{"other": stringNode("private")}), wantPath: "/items/0/id"},
{name: "mapping field", item: identityRecord(map[string]*document.Node{"id": {Kind: document.Mapping}}), wantPath: "/items/0/id"},
{name: "sequence field", item: identityRecord(map[string]*document.Node{"id": {Kind: document.Sequence}}), wantPath: "/items/0/id"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{test.item}}
_, diagnostics := validateSequenceIdentity(sequence, Keys("items"), []string{"id"}, sequenceIdentityDiagnosticCodes{invalid: CodeOptionsInvalid, conflict: CodeComposeConflict})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOptionsInvalid || diagnostics[0].Path.String() != test.wantPath {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if diagnostics[0].Message == "" || strings.Contains(diagnostics[0].Message, "private") {
t.Fatalf("diagnostic exposed value or lacked message: %#v", diagnostics[0])
}
})
}
}
func TestSequenceIdentityExcludesStructuralExtendsField(t *testing.T) {
item := identityRecord(map[string]*document.Node{
"extends": nullNode(),
"name": stringNode("ordinary payload"),
})
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{item}}
_, diagnostics := validateSequenceIdentity(sequence, Keys("items"), []string{"extends"}, sequenceIdentityDiagnosticCodes{invalid: CodeComposeInvalid, conflict: CodeComposeConflict})
if len(diagnostics) != 1 || diagnostics[0].Path.String() != "/items/0/extends" || diagnostics[0].Code != CodeComposeInvalid {
t.Fatalf("diagnostics = %#v, want structural field treated as absent", diagnostics)
}
}
func TestSequenceIdentityUsesLiteralPunctuationFieldNames(t *testing.T) {
item := identityRecord(map[string]*document.Node{"metadata.id": stringNode("literal"), "metadata": identityRecord(map[string]*document.Node{"id": stringNode("nested")})})
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{item}}
identities, diagnostics := validateSequenceIdentity(sequence, Keys("items"), []string{"metadata.id"}, sequenceIdentityDiagnosticCodes{invalid: CodeComposeInvalid, conflict: CodeComposeConflict})
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if len(identities) != 1 {
t.Fatalf("identity index has %d entries, want 1", len(identities))
}
}
func TestValidateSequenceIdentityUsesCallerDiagnosticCodes(t *testing.T) {
item := identityRecord(map[string]*document.Node{"id": stringNode("same")})
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{item, document.Clone(item)}}
_, diagnostics := validateSequenceIdentity(sequence, Keys("items"), []string{"id"}, sequenceIdentityDiagnosticCodes{invalid: CodeOptionsInvalid, conflict: CodeValidateMutation})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeValidateMutation {
t.Fatalf("diagnostics = %#v, want caller-selected conflict code", diagnostics)
}
}
func identityRecord(fields map[string]*document.Node) *document.Node {
entries := make([]document.Entry, 0, len(fields))
keys := make([]string, 0, len(fields))
for key := range fields {
keys = append(keys, key)
}
sort.Strings(keys)
for _, key := range keys {
value := fields[key]
entries = append(entries, document.Entry{Key: key, Value: value, ValueLocation: value.Location})
}
return &document.Node{Kind: document.Mapping, Mapping: entries, Location: document.Location{SourceID: "source", Line: 2}}
}
func stringNode(value string) *document.Node {
return &document.Node{Kind: document.String, Text: value}
}
func numberNode(value string) *document.Node {
return &document.Node{Kind: document.Number, Text: value}
}
func booleanNode(value string) *document.Node {
return &document.Node{Kind: document.Boolean, Text: value}
}
func nullNode() *document.Node { return &document.Node{Kind: document.Null} }