diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 57ab828..c6861d9 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -124,6 +124,14 @@ Run `./scripts/check.sh` before committing. ## Stage 2 — Expand one sibling scope +**Completed 2026-09-29.** A private, non-activated resolver indexes sibling +definitions, follows parent dependencies with an explicit stack, memoizes +unresolved enclosing templates, consumes each mapping's own relationship, and +retains transfer/control evidence. Safe diagnostics cover missing or non-mapping +parents, invalid reference types, and cycles. Focused tests include forward and +backward references, shared chains, exact unusual names, null cancellation, +independent results, and a deep cycle. `./scripts/check.sh` passed. + Add a private inheritance resolver, callable only from engine tests initially. Index effective sibling definitions. Resolve single-parent dependencies with visiting/resolved states and memoized enclosing-chain templates. Use an explicit diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go new file mode 100644 index 0000000..dab7985 --- /dev/null +++ b/internal/engine/inheritance.go @@ -0,0 +1,176 @@ +package engine + +import ( + "slices" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +const inheritanceKey = "extends" + +// These private records keep structural evidence separate from acquired source +// records. Later inspection can project a transfer onto individual descendants. +type inheritanceTransfer struct { + from Path + to Path + reference Path + locations []Location +} + +type inheritanceControl struct { + path Path + locations []Location + cancelled bool +} + +type inheritanceScope struct { + root *document.Node + transfers []inheritanceTransfer + controls []inheritanceControl +} + +type siblingFrame struct { + name string + parent string + hasParent bool + entered bool +} + +// expandSiblingDefinitions resolves only the mappings directly in scope. +// Nested instructions remain in the returned templates for destination-local +// resolution. This private entry point does not participate in public loading. +func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanceScope, []Diagnostic) { + if scope == nil || scope.Kind != document.Mapping { + return nil, []Diagnostic{{ + Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, + Message: "inheritance scope must be a mapping", + }} + } + + byName := make(map[string]document.Entry, len(scope.Mapping)) + names := make([]string, 0, len(scope.Mapping)) + for _, entry := range scope.Mapping { + byName[entry.Key] = entry + names = append(names, entry.Key) + } + slices.Sort(names) + + resolved := make(map[string]*document.Node, len(byName)) + state := make(map[string]uint8, len(byName)) + result := &inheritanceScope{} + for _, name := range names { + if byName[name].Value.Kind != document.Mapping || state[name] == 2 { + continue + } + stack := []siblingFrame{{name: name}} + for len(stack) != 0 { + frame := &stack[len(stack)-1] + entry := byName[frame.name] + path := scopePath.Key(frame.name) + if !frame.entered { + state[frame.name] = 1 + frame.entered = true + reference, found := inheritanceDirective(entry.Value) + if found { + referencePath := path.Key(inheritanceKey) + location := publicLocation(reference.Value.Location) + result.controls = append(result.controls, inheritanceControl{ + path: referencePath, locations: []Location{location}, + cancelled: reference.Value.Kind == document.Null, + }) + switch reference.Value.Kind { + case document.Null: + case document.String: + frame.parent, frame.hasParent = reference.Value.Text, true + parent, exists := byName[frame.parent] + if !exists { + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceMissing, referencePath, location, nil, + "referenced sibling does not exist")} + } + if parent.Value.Kind != document.Mapping { + related := []Location{publicLocation(parent.Value.Location)} + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, related, + "referenced sibling is not a mapping")} + } + if state[frame.parent] == 1 { + related := []Location{publicLocation(parent.Value.Location)} + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, related, + "sibling inheritance contains a cycle")} + } + if state[frame.parent] == 0 { + stack = append(stack, siblingFrame{name: frame.parent}) + continue + } + default: + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil, + "inheritance reference must be a sibling name or null")} + } + } + } + + payload := withoutInheritanceDirective(entry.Value) + if frame.hasParent { + parent := resolved[frame.parent] + merged := document.Clone(parent) + mergeRecursiveMappings(merged, payload, path, new(mappingIndex), recursiveMergeHooks{}) + merged.Location = entry.Value.Location + resolved[frame.name] = merged + reference := path.Key(inheritanceKey) + control := inheritanceDirectiveLocation(entry.Value) + result.transfers = append(result.transfers, inheritanceTransfer{ + from: scopePath.Key(frame.parent), to: path, + reference: reference, locations: []Location{control}, + }) + } else { + resolved[frame.name] = document.Clone(payload) + } + state[frame.name] = 2 + stack = stack[:len(stack)-1] + } + } + + root := *scope + root.Mapping = make([]document.Entry, len(scope.Mapping)) + for index, entry := range scope.Mapping { + root.Mapping[index] = entry + if resolved[entry.Key] != nil { + root.Mapping[index].Value = resolved[entry.Key] + } else { + root.Mapping[index].Value = document.Clone(entry.Value) + } + } + result.root = &root + return result, nil +} + +func inheritanceDirective(node *document.Node) (document.Entry, bool) { + for _, entry := range node.Mapping { + if entry.Key == inheritanceKey { + return entry, true + } + } + return document.Entry{}, false +} + +func inheritanceDirectiveLocation(node *document.Node) Location { + entry, _ := inheritanceDirective(node) + return publicLocation(entry.Value.Location) +} + +func withoutInheritanceDirective(node *document.Node) *document.Node { + payload := *node + payload.Mapping = make([]document.Entry, 0, len(node.Mapping)) + for _, entry := range node.Mapping { + if entry.Key != inheritanceKey { + payload.Mapping = append(payload.Mapping, entry) + } + } + return &payload +} + +func inheritanceDiagnostic(code Code, path Path, primary Location, related []Location, message string) Diagnostic { + return Diagnostic{ + Code: code, Severity: SeverityError, Path: path, Message: message, + Primary: &primary, Related: related, + } +} diff --git a/internal/engine/inheritance_test.go b/internal/engine/inheritance_test.go new file mode 100644 index 0000000..ac1faf4 --- /dev/null +++ b/internal/engine/inheritance_test.go @@ -0,0 +1,141 @@ +package engine + +import ( + "reflect" + "strconv" + "strings" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestSiblingInheritanceResolvesReferencesAndKeepsInputs(t *testing.T) { + tests := []struct { + name, input string + path Path + text string + transfers int + controls int + }{ + { + name: "forward reference", input: "child: {extends: parent, value: own}\nparent: {value: parent, kept: preserved}\n", + path: Keys("child", "kept"), text: "preserved", transfers: 1, controls: 1, + }, + { + name: "backward reference", input: "parent: {value: parent, kept: preserved}\nchild: {extends: parent, value: own}\n", + path: Keys("child", "value"), text: "own", transfers: 1, controls: 1, + }, + { + name: "chain and shared parent", input: "last: {extends: middle, enabled: false}\nfirst: {value: original}\nmiddle: {extends: first, extra: middle}\nother: {extends: first}\n", + path: Keys("last", "value"), text: "original", transfers: 3, controls: 3, + }, + { + name: "exact empty sibling name", input: "child: {extends: \"\"}\n\"\": {value: empty}\n", + path: Keys("child", "value"), text: "empty", transfers: 1, controls: 1, + }, + { + name: "exact unusual sibling name", input: "child: {extends: \"a/b~c\"}\n\"a/b~c\": {value: unusual}\n", + path: Keys("child", "value"), text: "unusual", transfers: 1, controls: 1, + }, + { + name: "null cancellation", input: "child: {extends: null, value: own}\nparent: {value: parent}\n", + path: Keys("child", "value"), text: "own", controls: 1, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := parseCompositionDocument(t, "source-1", test.input) + before := document.Clone(input) + result, diagnostics := expandSiblingDefinitions(input, Path{}) + if len(diagnostics) != 0 { + t.Fatalf("diagnostics = %#v", diagnostics) + } + if len(result.transfers) != test.transfers || len(result.controls) != test.controls { + t.Fatalf("evidence = %#v, %#v", result.transfers, result.controls) + } + node, found := documentAtPath(result.root, test.path) + if !found || node.Text != test.text { + t.Fatalf("%s = %#v, %v", test.path.String(), node, found) + } + if _, found := documentAtPath(result.root, Keys("child", inheritanceKey)); found { + t.Fatal("child directive remained in expanded payload") + } + if !reflect.DeepEqual(input, before) { + t.Fatal("resolver changed input") + } + node.Text = "changed" + if !reflect.DeepEqual(input, before) { + t.Fatal("expanded payload aliases input") + } + }) + } +} + +func TestSiblingInheritanceTracksTransferAndIndependentChildren(t *testing.T) { + input := parseCompositionDocument(t, "source-1", "first: {value: parent}\nsecond: {extends: first}\nthird: {extends: first}\n") + result, diagnostics := expandSiblingDefinitions(input, Keys("catalog")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + if len(result.transfers) != 2 || result.transfers[0].from.String() != "/catalog/first" || + result.transfers[0].to.String() != "/catalog/second" || + result.transfers[0].reference.String() != "/catalog/second/extends" || + len(result.transfers[0].locations) != 1 || result.transfers[0].locations[0].SourceID != "source-1" { + t.Fatalf("transfers = %#v", result.transfers) + } + second, _ := documentAtPath(result.root, Keys("second", "value")) + third, _ := documentAtPath(result.root, Keys("third", "value")) + second.Text = "changed" + if third.Text != "parent" { + t.Fatal("siblings share mutable expanded values") + } +} + +func TestSiblingInheritanceRejectsInvalidRelationships(t *testing.T) { + tests := []struct { + name, input, path string + code Code + related bool + }{ + {"missing", "child: {extends: private-reference-canary}\n", "/child/extends", CodeInheritanceMissing, false}, + {"nonmapping parent", "child: {extends: parent}\nparent: null\n", "/child/extends", CodeInheritanceInvalid, true}, + {"boolean reference", "child: {extends: true}\n", "/child/extends", CodeInheritanceInvalid, false}, + {"sequence reference", "child: {extends: [parent]}\n", "/child/extends", CodeInheritanceInvalid, false}, + {"self reference", "child: {extends: child}\n", "/child/extends", CodeInheritanceCycle, true}, + {"longer cycle", "a: {extends: b}\nb: {extends: c}\nc: {extends: a}\n", "/c/extends", CodeInheritanceCycle, true}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + input := parseCompositionDocument(t, "source-1", test.input) + result, diagnostics := expandSiblingDefinitions(input, Path{}) + if result != nil || len(diagnostics) != 1 { + t.Fatalf("result and diagnostics = %#v, %#v", result, diagnostics) + } + problem := diagnostics[0] + if problem.Code != test.code || problem.Path.String() != test.path || problem.Primary == nil || + problem.Primary.SourceID != "source-1" || problem.Primary.Line < 1 || + (len(problem.Related) != 0) != test.related || strings.Contains(problem.Message, "private-reference-canary") { + t.Fatalf("diagnostic = %#v", problem) + } + }) + } +} + +func TestSiblingInheritanceDetectsDeepCycleWithoutRecursiveDependencyWalk(t *testing.T) { + const count = 2048 + entries := make([]document.Entry, count) + for index := range entries { + location := document.Location{SourceID: "source-1", Line: index + 1, Column: 1} + parent := strconv.Itoa((index + 1) % count) + entries[index] = document.Entry{ + Key: strconv.Itoa(index), Value: &document.Node{Kind: document.Mapping, Location: location, + Mapping: []document.Entry{{Key: inheritanceKey, Value: &document.Node{ + Kind: document.String, Text: parent, Location: location, + }}}}, + } + } + result, diagnostics := expandSiblingDefinitions(&document.Node{Kind: document.Mapping, Mapping: entries}, Path{}) + if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle { + t.Fatalf("deep cycle result = %#v, diagnostics = %#v", result, diagnostics) + } +} diff --git a/internal/engine/records.go b/internal/engine/records.go index 97db8bc..a2df796 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -160,6 +160,11 @@ const ( CodeOverrideLimitExceeded Code = "override.limit_exceeded" CodeExplainLimitExceeded Code = "explain.limit_exceeded" + + CodeInheritanceInvalid Code = "inheritance.invalid" + CodeInheritanceMissing Code = "inheritance.missing" + CodeInheritanceCycle Code = "inheritance.cycle" + CodeInheritanceVersion Code = "inheritance.version" ) // Location identifies a position in a configuration source. Zero line and column