Resolve sibling inheritance with memoized parent templates
This commit is contained in:
@@ -124,6 +124,14 @@ Run `./scripts/check.sh` before committing.
|
|||||||
|
|
||||||
## Stage 2 — Expand one sibling scope
|
## 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.
|
Add a private inheritance resolver, callable only from engine tests initially.
|
||||||
Index effective sibling definitions. Resolve single-parent dependencies with
|
Index effective sibling definitions. Resolve single-parent dependencies with
|
||||||
visiting/resolved states and memoized enclosing-chain templates. Use an explicit
|
visiting/resolved states and memoized enclosing-chain templates. Use an explicit
|
||||||
|
|||||||
176
internal/engine/inheritance.go
Normal file
176
internal/engine/inheritance.go
Normal file
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
141
internal/engine/inheritance_test.go
Normal file
141
internal/engine/inheritance_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -160,6 +160,11 @@ const (
|
|||||||
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||||
|
|
||||||
CodeExplainLimitExceeded Code = "explain.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
|
// Location identifies a position in a configuration source. Zero line and column
|
||||||
|
|||||||
Reference in New Issue
Block a user