From 4207110e2f4e7346ac5b7083ff30cf6e82ca162a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Tue, 29 Sep 2026 22:31:07 +0000 Subject: [PATCH] Resolve nested inheritance in destination scopes --- docs/roadmap/implementation.md | 9 + internal/engine/inheritance.go | 36 ++++ internal/engine/inheritance_walk.go | 237 +++++++++++++++++++++++ internal/engine/inheritance_walk_test.go | 196 +++++++++++++++++++ 4 files changed, 478 insertions(+) create mode 100644 internal/engine/inheritance_walk.go create mode 100644 internal/engine/inheritance_walk_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c6861d9..a55b34a 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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 +**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 fully resolved destination subtrees. After materializing an enclosing mapping, resolve its nested sibling scopes independently. Visit sequence-contained mappings; diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index dab7985..cdafdb0 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -40,6 +40,10 @@ type siblingFrame struct { // 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) { + return expandSiblingDefinitionsWithLimits(scope, scopePath, defaultProjectionLimits()) +} + +func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, limits projectionLimits) (*inheritanceScope, []Diagnostic) { if scope == nil || scope.Kind != document.Mapping { return nil, []Diagnostic{{ 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)) state := make(map[string]uint8, len(byName)) 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 { if byName[name].Value.Kind != document.Mapping || state[name] == 2 { continue @@ -109,6 +128,18 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc } 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 { parent := resolved[frame.parent] merged := document.Clone(parent) @@ -124,6 +155,8 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc } else { resolved[frame.name] = document.Clone(payload) } + count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes) + retained += 1 + count state[frame.name] = 2 stack = stack[:len(stack)-1] } @@ -140,6 +173,9 @@ func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanc } } result.root = &root + if !inheritanceLowerBoundWithin(result.root, limits.nodes) { + return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)} + } return result, nil } diff --git a/internal/engine/inheritance_walk.go b/internal/engine/inheritance_walk.go new file mode 100644 index 0000000..0469782 --- /dev/null +++ b/internal/engine/inheritance_walk.go @@ -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, + } +} diff --git a/internal/engine/inheritance_walk_test.go b/internal/engine/inheritance_walk_test.go new file mode 100644 index 0000000..cd0d7cc --- /dev/null +++ b/internal/engine/inheritance_walk_test.go @@ -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) + } + }) + } +}