diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index ba39da8..4c68024 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: ready for implementation; no stages completed. +Status: implementation in progress; Stage 1 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -175,6 +175,9 @@ Validation: `go test ./internal/engine -run 'SequenceIdentity|SequenceNumber'` and the everyday check. Retain the old registration path until its adapters are replaced; do not generalize unrelated YAML scalar decoding. +**Completion:** Implemented and verified with the focused identity tests and +`./scripts/check.sh`. + ## Stage 2 — Compile Exact And Dynamic Application Constraints Scope: private registration model, selector matching, overlap validation. diff --git a/internal/engine/sequence_identity.go b/internal/engine/sequence_identity.go new file mode 100644 index 0000000..16117d0 --- /dev/null +++ b/internal/engine/sequence_identity.go @@ -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 } diff --git a/internal/engine/sequence_identity_test.go b/internal/engine/sequence_identity_test.go new file mode 100644 index 0000000..83990cb --- /dev/null +++ b/internal/engine/sequence_identity_test.go @@ -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} }