Resolve nested inheritance in destination scopes
This commit is contained in:
@@ -151,6 +151,15 @@ fields, not stack layout or exact prose. Run focused tests and the everyday chec
|
|||||||
|
|
||||||
## Stage 3 — Resolve nested destination scopes and bound expansion
|
## Stage 3 — Resolve nested destination scopes and bound expansion
|
||||||
|
|
||||||
|
**Completed 2026-09-29.** Private expansion now walks from enclosing mappings
|
||||||
|
into destination-local nested scopes, including mappings within sequences.
|
||||||
|
Root and direct sequence-element string references fail; null cancellation is
|
||||||
|
consumed. Logical materialization uses an early non-directive lower bound and
|
||||||
|
final node/depth checks under projection limits, with no global cache. Focused
|
||||||
|
tests cover destination-specific nested values, three-generation chains,
|
||||||
|
relationship replacement/cancellation, a destination-only cycle, sequence
|
||||||
|
scopes, invalid positions, and budget boundaries. `./scripts/check.sh` passed.
|
||||||
|
|
||||||
Extend to the complete effective tree. Distinguish enclosing-chain templates from
|
Extend to the complete effective tree. Distinguish enclosing-chain templates from
|
||||||
fully resolved destination subtrees. After materializing an enclosing mapping,
|
fully resolved destination subtrees. After materializing an enclosing mapping,
|
||||||
resolve its nested sibling scopes independently. Visit sequence-contained mappings;
|
resolve its nested sibling scopes independently. Visit sequence-contained mappings;
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ type siblingFrame struct {
|
|||||||
// Nested instructions remain in the returned templates for destination-local
|
// Nested instructions remain in the returned templates for destination-local
|
||||||
// resolution. This private entry point does not participate in public loading.
|
// resolution. This private entry point does not participate in public loading.
|
||||||
func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanceScope, []Diagnostic) {
|
func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanceScope, []Diagnostic) {
|
||||||
|
return expandSiblingDefinitionsWithLimits(scope, scopePath, defaultProjectionLimits())
|
||||||
|
}
|
||||||
|
|
||||||
|
func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
|
||||||
if scope == nil || scope.Kind != document.Mapping {
|
if scope == nil || scope.Kind != document.Mapping {
|
||||||
return nil, []Diagnostic{{
|
return nil, []Diagnostic{{
|
||||||
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
|
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
|
||||||
@@ -58,6 +62,21 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc
|
|||||||
resolved := make(map[string]*document.Node, len(byName))
|
resolved := make(map[string]*document.Node, len(byName))
|
||||||
state := make(map[string]uint8, len(byName))
|
state := make(map[string]uint8, len(byName))
|
||||||
result := &inheritanceScope{}
|
result := &inheritanceScope{}
|
||||||
|
retained := 1 // scope mapping node
|
||||||
|
for _, entry := range scope.Mapping {
|
||||||
|
if entry.Value.Kind == document.Mapping {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
remaining := limits.nodes - retained - 1
|
||||||
|
if limits.nodes >= 0 && remaining < 0 {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
count, within := inheritanceLowerBoundCount(entry.Value, remaining)
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
|
||||||
|
}
|
||||||
|
retained += 1 + count // key and value subtree
|
||||||
|
}
|
||||||
for _, name := range names {
|
for _, name := range names {
|
||||||
if byName[name].Value.Kind != document.Mapping || state[name] == 2 {
|
if byName[name].Value.Kind != document.Mapping || state[name] == 2 {
|
||||||
continue
|
continue
|
||||||
@@ -109,6 +128,18 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc
|
|||||||
}
|
}
|
||||||
|
|
||||||
payload := withoutInheritanceDirective(entry.Value)
|
payload := withoutInheritanceDirective(entry.Value)
|
||||||
|
if limits.nodes >= 0 {
|
||||||
|
remaining := limits.nodes - retained - 1 // the sibling key
|
||||||
|
within := false
|
||||||
|
if frame.hasParent {
|
||||||
|
within = inheritanceOverlayLowerBoundWithin(resolved[frame.parent], payload, remaining)
|
||||||
|
} else {
|
||||||
|
within = inheritanceLowerBoundWithin(payload, remaining)
|
||||||
|
}
|
||||||
|
if !within {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(path, entry.Value.Location)}
|
||||||
|
}
|
||||||
|
}
|
||||||
if frame.hasParent {
|
if frame.hasParent {
|
||||||
parent := resolved[frame.parent]
|
parent := resolved[frame.parent]
|
||||||
merged := document.Clone(parent)
|
merged := document.Clone(parent)
|
||||||
@@ -124,6 +155,8 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc
|
|||||||
} else {
|
} else {
|
||||||
resolved[frame.name] = document.Clone(payload)
|
resolved[frame.name] = document.Clone(payload)
|
||||||
}
|
}
|
||||||
|
count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes)
|
||||||
|
retained += 1 + count
|
||||||
state[frame.name] = 2
|
state[frame.name] = 2
|
||||||
stack = stack[:len(stack)-1]
|
stack = stack[:len(stack)-1]
|
||||||
}
|
}
|
||||||
@@ -140,6 +173,9 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
result.root = &root
|
result.root = &root
|
||||||
|
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
|
||||||
|
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)}
|
||||||
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
237
internal/engine/inheritance_walk.go
Normal file
237
internal/engine/inheritance_walk.go
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"cmp"
|
||||||
|
"slices"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
type inheritanceWalker struct {
|
||||||
|
root **document.Node
|
||||||
|
limits projectionLimits
|
||||||
|
transfers []inheritanceTransfer
|
||||||
|
controls []inheritanceControl
|
||||||
|
}
|
||||||
|
|
||||||
|
// expandInheritanceTree resolves each destination scope after its enclosing
|
||||||
|
// relationships. It remains a private, test-only operation until the loading
|
||||||
|
// lifecycle also carries its complete provenance and inspection evidence.
|
||||||
|
func expandInheritanceTree(input *document.Node, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
|
||||||
|
if input == nil || input.Kind != document.Mapping {
|
||||||
|
return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError,
|
||||||
|
Message: "inheritance requires a mapping document root"}}
|
||||||
|
}
|
||||||
|
root := input
|
||||||
|
walker := inheritanceWalker{root: &root, limits: limits}
|
||||||
|
if problem := walker.visit(&root, Path{}, true); problem != nil {
|
||||||
|
return nil, []Diagnostic{*problem}
|
||||||
|
}
|
||||||
|
if problem := inheritanceFinalBudget(root, limits); problem != nil {
|
||||||
|
return nil, []Diagnostic{*problem}
|
||||||
|
}
|
||||||
|
return &inheritanceScope{root: root, transfers: walker.transfers, controls: walker.controls}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool) *Diagnostic {
|
||||||
|
switch (*node).Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
if unscoped {
|
||||||
|
reference, found := inheritanceDirective(*node)
|
||||||
|
if found {
|
||||||
|
referencePath := path.Key(inheritanceKey)
|
||||||
|
location := publicLocation(reference.Value.Location)
|
||||||
|
if reference.Value.Kind != document.Null {
|
||||||
|
problem := inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
|
||||||
|
"inheritance reference has no enclosing sibling scope")
|
||||||
|
return &problem
|
||||||
|
}
|
||||||
|
w.controls = append(w.controls, inheritanceControl{
|
||||||
|
path: referencePath, locations: []Location{location}, cancelled: true,
|
||||||
|
})
|
||||||
|
*node = withoutInheritanceDirective(*node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolved, diagnostics := expandSiblingDefinitionsWithLimits(*node, path, w.limits)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return &diagnostics[0]
|
||||||
|
}
|
||||||
|
*node = resolved.root
|
||||||
|
w.transfers = append(w.transfers, resolved.transfers...)
|
||||||
|
w.controls = append(w.controls, resolved.controls...)
|
||||||
|
if !inheritanceLowerBoundWithin(*w.root, w.limits.nodes) {
|
||||||
|
problem := inheritanceBudgetDiagnostic(path, (*node).Location)
|
||||||
|
return &problem
|
||||||
|
}
|
||||||
|
indices := make([]int, len((*node).Mapping))
|
||||||
|
for index := range indices {
|
||||||
|
indices[index] = index
|
||||||
|
}
|
||||||
|
slices.SortFunc(indices, func(left, right int) int {
|
||||||
|
return cmp.Compare((*node).Mapping[left].Key, (*node).Mapping[right].Key)
|
||||||
|
})
|
||||||
|
for _, index := range indices {
|
||||||
|
entry := &(*node).Mapping[index]
|
||||||
|
if problem := w.visit(&entry.Value, path.Key(entry.Key), false); problem != nil {
|
||||||
|
return problem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index := range (*node).Sequence {
|
||||||
|
child := &(*node).Sequence[index]
|
||||||
|
if problem := w.visit(child, path.Index(index), (*child).Kind == document.Mapping); problem != nil {
|
||||||
|
return problem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// A directive may disappear during later destination resolution, so only
|
||||||
|
// non-directive nodes form a safe early lower bound on final output size.
|
||||||
|
func inheritanceLowerBoundWithin(root *document.Node, limit int) bool {
|
||||||
|
_, within := inheritanceLowerBoundCount(root, limit)
|
||||||
|
return within
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceLowerBoundCount(root *document.Node, limit int) (int, bool) {
|
||||||
|
if limit < 0 {
|
||||||
|
limit = int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
stack := []*document.Node{root}
|
||||||
|
count := 0
|
||||||
|
for len(stack) != 0 {
|
||||||
|
node := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if node == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if count > limit {
|
||||||
|
return count, false
|
||||||
|
}
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
for _, entry := range node.Mapping {
|
||||||
|
if entry.Key == inheritanceKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++ // mapping key
|
||||||
|
stack = append(stack, entry.Value)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
stack = append(stack, node.Sequence...)
|
||||||
|
}
|
||||||
|
if count > limit {
|
||||||
|
return count, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return count, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Count the materialized mapping shape before cloning a memoized parent.
|
||||||
|
// Relationship fields are excluded because later destination resolution
|
||||||
|
// consumes them; their omission makes this a safe lower bound.
|
||||||
|
func inheritanceOverlayLowerBoundWithin(lower, upper *document.Node, limit int) bool {
|
||||||
|
if limit < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
index := new(mappingIndex)
|
||||||
|
count := 0
|
||||||
|
var visit func(*document.Node, *document.Node) bool
|
||||||
|
visit = func(base, incoming *document.Node) bool {
|
||||||
|
if base.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||||
|
added, within := inheritanceLowerBoundCount(incoming, limit-count)
|
||||||
|
count += added
|
||||||
|
return within
|
||||||
|
}
|
||||||
|
count++ // mapping node
|
||||||
|
if count > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, entry := range base.Mapping {
|
||||||
|
if entry.Key == inheritanceKey {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++ // mapping key
|
||||||
|
if count > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
position := index.find(incoming, entry.Key)
|
||||||
|
if position >= 0 {
|
||||||
|
if !visit(entry.Value, incoming.Mapping[position].Value) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
added, within := inheritanceLowerBoundCount(entry.Value, limit-count)
|
||||||
|
count += added
|
||||||
|
if !within {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range incoming.Mapping {
|
||||||
|
if entry.Key == inheritanceKey || index.find(base, entry.Key) >= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
count++ // mapping key
|
||||||
|
if count > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
added, within := inheritanceLowerBoundCount(entry.Value, limit-count)
|
||||||
|
count += added
|
||||||
|
if !within || count > limit {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return visit(lower, upper)
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceFinalBudget(root *document.Node, limits projectionLimits) *Diagnostic {
|
||||||
|
type pending struct {
|
||||||
|
node *document.Node
|
||||||
|
path Path
|
||||||
|
}
|
||||||
|
stack := []pending{{node: root}}
|
||||||
|
count := 0
|
||||||
|
for len(stack) != 0 {
|
||||||
|
item := stack[len(stack)-1]
|
||||||
|
stack = stack[:len(stack)-1]
|
||||||
|
if len(item.path.segments) > limits.depth {
|
||||||
|
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
|
||||||
|
return &problem
|
||||||
|
}
|
||||||
|
count++
|
||||||
|
if limits.nodes >= 0 && count > limits.nodes {
|
||||||
|
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
|
||||||
|
return &problem
|
||||||
|
}
|
||||||
|
switch item.node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
count += len(item.node.Mapping) // mapping keys
|
||||||
|
for index := len(item.node.Mapping) - 1; index >= 0; index-- {
|
||||||
|
entry := item.node.Mapping[index]
|
||||||
|
stack = append(stack, pending{node: entry.Value, path: item.path.Key(entry.Key)})
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
for index := len(item.node.Sequence) - 1; index >= 0; index-- {
|
||||||
|
stack = append(stack, pending{node: item.node.Sequence[index], path: item.path.Index(index)})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if limits.nodes >= 0 && count > limits.nodes {
|
||||||
|
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
|
||||||
|
return &problem
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func inheritanceBudgetDiagnostic(path Path, location document.Location) Diagnostic {
|
||||||
|
primary := publicLocation(location)
|
||||||
|
return Diagnostic{
|
||||||
|
Code: CodeSchemaProjectionLimit, Severity: SeverityError, Path: path,
|
||||||
|
Message: "expanded configuration exceeds the projection limit", Primary: &primary,
|
||||||
|
}
|
||||||
|
}
|
||||||
196
internal/engine/inheritance_walk_test.go
Normal file
196
internal/engine/inheritance_walk_test.go
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
package engine
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestNestedInheritanceUsesEachDestinationScope(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `presets:
|
||||||
|
base:
|
||||||
|
settings:
|
||||||
|
primary: {width: 10}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
custom:
|
||||||
|
extends: base
|
||||||
|
settings:
|
||||||
|
primary: {width: 20}
|
||||||
|
`)
|
||||||
|
before := document.Clone(input)
|
||||||
|
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
for path, want := range map[string]string{
|
||||||
|
"/presets/base/settings/secondary/width": "10",
|
||||||
|
"/presets/custom/settings/secondary/width": "20",
|
||||||
|
} {
|
||||||
|
parsed, _ := ParsePath(path)
|
||||||
|
node, found := documentAtPath(result.root, parsed)
|
||||||
|
if !found || node.Text != want {
|
||||||
|
t.Errorf("%s = %#v, found %v", path, node, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(result.transfers) != 3 || !reflect.DeepEqual(input, before) {
|
||||||
|
t.Fatalf("transfers = %#v; input changed = %v", result.transfers, !reflect.DeepEqual(input, before))
|
||||||
|
}
|
||||||
|
if node, found := documentAtPath(result.root, Keys("presets", "custom", "settings", "secondary", inheritanceKey)); found {
|
||||||
|
t.Fatalf("nested directive survived: %#v", node)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedInheritanceSupportsChainsCancellationAndContainerReplacement(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `catalog:
|
||||||
|
alpha:
|
||||||
|
settings:
|
||||||
|
first: {value: one}
|
||||||
|
second: {extends: first}
|
||||||
|
beta:
|
||||||
|
extends: alpha
|
||||||
|
settings:
|
||||||
|
first: {value: two}
|
||||||
|
gamma:
|
||||||
|
extends: beta
|
||||||
|
settings:
|
||||||
|
first: {value: three}
|
||||||
|
cancelled:
|
||||||
|
extends: alpha
|
||||||
|
settings:
|
||||||
|
second: {extends: null, value: own}
|
||||||
|
replaced:
|
||||||
|
extends: alpha
|
||||||
|
settings: none
|
||||||
|
`)
|
||||||
|
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
checks := map[string]string{
|
||||||
|
"/catalog/alpha/settings/second/value": "one",
|
||||||
|
"/catalog/beta/settings/second/value": "two",
|
||||||
|
"/catalog/gamma/settings/second/value": "three",
|
||||||
|
"/catalog/cancelled/settings/second/value": "own",
|
||||||
|
"/catalog/replaced/settings": "none",
|
||||||
|
}
|
||||||
|
for pointer, want := range checks {
|
||||||
|
path, _ := ParsePath(pointer)
|
||||||
|
node, found := documentAtPath(result.root, path)
|
||||||
|
if !found || node.Text != want {
|
||||||
|
t.Errorf("%s = %#v, found %v", pointer, node, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, found := documentAtPath(result.root, Keys("catalog", "replaced", "settings", "second")); found {
|
||||||
|
t.Fatal("replaced container retained an active child")
|
||||||
|
}
|
||||||
|
if _, found := documentAtPath(result.root, Keys("catalog", "cancelled", "settings", "second", inheritanceKey)); found {
|
||||||
|
t.Fatal("cancelled nested directive survived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedInheritanceCanReplaceAnInheritedRelationship(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `base:
|
||||||
|
settings:
|
||||||
|
first: {value: old}
|
||||||
|
other: {value: new}
|
||||||
|
selected: {extends: first}
|
||||||
|
custom:
|
||||||
|
extends: base
|
||||||
|
settings:
|
||||||
|
selected: {extends: other}
|
||||||
|
`)
|
||||||
|
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
base, found := documentAtPath(result.root, Keys("base", "settings", "selected", "value"))
|
||||||
|
if !found || base.Text != "old" {
|
||||||
|
t.Fatalf("base relationship = %#v, %v", base, found)
|
||||||
|
}
|
||||||
|
custom, found := documentAtPath(result.root, Keys("custom", "settings", "selected", "value"))
|
||||||
|
if !found || custom.Text != "new" {
|
||||||
|
t.Fatalf("replaced relationship = %#v, %v", custom, found)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedInheritanceRejectsDestinationCycleAndInvalidPositions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, input, path string
|
||||||
|
code Code
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "destination-only cycle", input: `base:
|
||||||
|
settings:
|
||||||
|
a: {value: one}
|
||||||
|
b: {extends: a}
|
||||||
|
custom:
|
||||||
|
extends: base
|
||||||
|
settings:
|
||||||
|
a: {extends: b}
|
||||||
|
`, path: "/custom/settings/b/extends", code: CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
{"root reference", "extends: anything\nentry: {value: one}\n", "/extends", CodeInheritanceInvalid},
|
||||||
|
{"list element reference", "items: [{extends: anything, value: one}]\n", "/items/0/extends", CodeInheritanceInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", test.input)
|
||||||
|
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
|
||||||
|
if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != test.code ||
|
||||||
|
diagnostics[0].Path.String() != test.path || diagnostics[0].Primary == nil {
|
||||||
|
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNestedInheritanceResolvesScopesInsideSequences(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", `items:
|
||||||
|
- primary: {value: first}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
- extends: null
|
||||||
|
zeta: {extends: alpha}
|
||||||
|
alpha: {value: second}
|
||||||
|
`)
|
||||||
|
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatal(diagnostics)
|
||||||
|
}
|
||||||
|
first, found := documentAtPath(result.root, Keys("items").Index(0).Key("secondary").Key("value"))
|
||||||
|
if !found || first.Text != "first" {
|
||||||
|
t.Fatalf("first sequence scope = %#v, %v", first, found)
|
||||||
|
}
|
||||||
|
second, found := documentAtPath(result.root, Keys("items").Index(1).Key("zeta").Key("value"))
|
||||||
|
if !found || second.Text != "second" {
|
||||||
|
t.Fatalf("second sequence scope = %#v, %v", second, found)
|
||||||
|
}
|
||||||
|
if _, found := documentAtPath(result.root, Keys("items").Index(1).Key(inheritanceKey)); found {
|
||||||
|
t.Fatal("sequence-element cancellation survived")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInheritanceExpansionHonorsOutputNodeAndDepthBudgets(t *testing.T) {
|
||||||
|
input := parseCompositionDocument(t, "source-1", "base: {value: one}\nchild: {extends: base}\n")
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
limits projectionLimits
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{name: "at node boundary", limits: projectionLimits{nodes: 9, depth: 2}, valid: true},
|
||||||
|
{name: "beyond node boundary", limits: projectionLimits{nodes: 8, depth: 2}},
|
||||||
|
{name: "at depth boundary", limits: projectionLimits{nodes: 20, depth: 2}, valid: true},
|
||||||
|
{name: "beyond depth boundary", limits: projectionLimits{nodes: 20, depth: 1}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, diagnostics := expandInheritanceTree(input, test.limits)
|
||||||
|
if test.valid {
|
||||||
|
if result == nil || len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
} else if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
|
||||||
|
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user