From 2718fbb8fe6175546b7a9723c8794774789402b8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 19 Oct 2026 15:18:25 +0000 Subject: [PATCH] Separate inheritance graph analysis and application --- internal/engine/inheritance.go | 2 +- internal/engine/inheritance_schedule.go | 341 +++++++++++++++++++ internal/engine/inheritance_schedule_test.go | 135 ++++++++ 3 files changed, 477 insertions(+), 1 deletion(-) create mode 100644 internal/engine/inheritance_schedule.go create mode 100644 internal/engine/inheritance_schedule_test.go diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index ff0e442..bacd0f8 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -84,7 +84,7 @@ func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, li return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil) } -func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { +func expandSiblingDefinitionsWithFrames(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { if scope == nil || scope.Kind != document.Mapping { return nil, []Diagnostic{{ Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go new file mode 100644 index 0000000..329392a --- /dev/null +++ b/internal/engine/inheritance_schedule.go @@ -0,0 +1,341 @@ +package engine + +import ( + "slices" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +type inheritanceTemplate struct { + name string + entry document.Entry + path Path + parent int + hasParent bool + hasDirective bool + cancelled bool + directive document.Entry +} + +type inheritanceGraph struct { + scope *document.Node + scopePath Path + nodes []inheritanceTemplate + edges []dependencyEdge + byName map[string]int +} + +// depthFirstTemplateOrder preserves the currently wired parent-first order +// while the loading path adopts the graph/application boundary. +func (graph *inheritanceGraph) depthFirstTemplateOrder() ([]int, []Diagnostic) { + const ( + unvisited = uint8(0) + active = uint8(1) + finished = uint8(2) + ) + state := make([]uint8, len(graph.nodes)) + order := make([]int, 0, len(graph.nodes)) + for start := range graph.nodes { + if state[start] != unvisited { + continue + } + stack := []int{start} + for len(stack) != 0 { + index := stack[len(stack)-1] + if state[index] == unvisited { + state[index] = active + node := graph.nodes[index] + if node.hasParent { + parent := node.parent + switch state[parent] { + case active: + referencePath := node.path.Key(inheritanceKey) + location := publicLocation(node.directive.Value.Location) + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, + []Location{publicLocation(graph.nodes[parent].entry.Value.Location)}, "sibling inheritance contains a cycle")} + case unvisited: + stack = append(stack, parent) + continue + } + } + } + state[index] = finished + order = append(order, index) + stack = stack[:len(stack)-1] + } + } + return order, nil +} + +func (graph *inheritanceGraph) depthFirstControls() []inheritanceControl { + visited := make([]bool, len(graph.nodes)) + controls := make([]inheritanceControl, 0, len(graph.nodes)) + type frame struct { + index int + entered bool + } + for start := range graph.nodes { + if visited[start] { + continue + } + stack := []frame{{index: start}} + for len(stack) != 0 { + current := &stack[len(stack)-1] + node := graph.nodes[current.index] + if !current.entered { + current.entered = true + visited[current.index] = true + if node.hasDirective { + controls = append(controls, inheritanceControl{ + path: node.path.Key(inheritanceKey), + locations: []Location{publicLocation(node.directive.Value.Location)}, + cancelled: node.cancelled, + }) + } + if node.hasParent && !visited[node.parent] { + stack = append(stack, frame{index: node.parent}) + continue + } + } + stack = stack[:len(stack)-1] + } + } + return controls +} + +// analyzeInheritanceGraph retains only direct mapping siblings as graph nodes. +// It validates declarations separately from applying their templates, leaving +// nested scopes for the existing destination-local walk. +func analyzeInheritanceGraph(scope *document.Node, scopePath Path) (*inheritanceGraph, []Diagnostic) { + if scope == nil || scope.Kind != document.Mapping { + return nil, []Diagnostic{{ + Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath, + Message: "inheritance scope must be a mapping", + }} + } + + graph := &inheritanceGraph{ + scope: scope, scopePath: scopePath, + byName: make(map[string]int, len(scope.Mapping)), + } + entries := make(map[string]document.Entry, len(scope.Mapping)) + for _, entry := range scope.Mapping { + entries[entry.Key] = entry + } + + names := make([]string, 0, len(scope.Mapping)) + for name, entry := range entries { + if entry.Value.Kind == document.Mapping { + names = append(names, name) + } + } + slices.Sort(names) + for _, name := range names { + index := len(graph.nodes) + graph.byName[name] = index + graph.nodes = append(graph.nodes, inheritanceTemplate{ + name: name, entry: entries[name], path: scopePath.Key(name), + }) + } + + for _, name := range names { + index := graph.byName[name] + node := &graph.nodes[index] + reference, found := inheritanceDirective(node.entry.Value) + if !found { + continue + } + node.hasDirective = true + node.directive = reference + referencePath := node.path.Key(inheritanceKey) + location := publicLocation(reference.Value.Location) + switch reference.Value.Kind { + case document.Null: + node.cancelled = true + case document.String: + parent, exists := entries[reference.Value.Text] + 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")} + } + node.parent = graph.byName[reference.Value.Text] + node.hasParent = true + graph.edges = append(graph.edges, dependencyEdge{prerequisite: node.parent, dependent: index}) + default: + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil, + "inheritance reference must be a sibling name or null")} + } + } + return graph, nil +} + +func (graph *inheritanceGraph) schedule() ([]int, []Diagnostic) { + keys := make([]dependencyKey, len(graph.nodes)) + for i, node := range graph.nodes { + keys[i] = dependencyKey{"definition", graph.scopePath.String(), node.path.String()} + } + order, cycle := scheduleDependencies(keys, graph.edges) + if len(cycle) == 0 { + return order, nil + } + + parent := graph.nodes[cycle[0]] + child := graph.nodes[cycle[1]] + referencePath := child.path.Key(inheritanceKey) + location := publicLocation(child.directive.Value.Location) + return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, + []Location{publicLocation(parent.entry.Value.Location)}, "sibling inheritance contains a cycle")} +} + +func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { + graph, diagnostics := analyzeInheritanceGraph(scope, scopePath) + if len(diagnostics) != 0 { + return nil, diagnostics + } + order, diagnostics := graph.depthFirstTemplateOrder() + if len(diagnostics) != 0 { + return nil, diagnostics + } + result, diagnostics := applyInheritanceTemplates(graph, order, limits, inputOrigins, budget...) + if len(diagnostics) == 0 { + result.controls = graph.depthFirstControls() + } + return result, diagnostics +} + +// applyInheritanceTemplates builds detached templates in the supplied +// dependency-ready order while retaining the existing origins, merge history, +// and node-accounting evidence. The root keeps its authored mapping order. +func applyInheritanceTemplates(graph *inheritanceGraph, order []int, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) { + resolved := make([]*document.Node, len(graph.nodes)) + resolvedOrigins := make([]map[string]Origin, len(graph.nodes)) + result := &inheritanceScope{} + counter := newRetainedNodeCounter(historyNodeLimit) + if len(budget) != 0 { + counter = budget[0] + } + retained := 1 // scope mapping node + for _, entry := range graph.scope.Mapping { + if entry.Value.Kind == document.Mapping { + continue + } + remaining := limits.nodes - retained - 1 + if limits.nodes >= 0 && remaining < 0 { + return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)} + } + count, within := inheritanceLowerBoundCount(entry.Value, remaining) + if !within { + return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath.Key(entry.Key), entry.Value.Location)} + } + retained += 1 + count + } + + for _, index := range order { + node := graph.nodes[index] + payload := withoutInheritanceDirective(node.entry.Value) + operationStart, replacementStart := len(result.operations), len(result.replacements) + childOrigins := directInheritanceOrigins(payload, node.path, inputOrigins) + if node.hasDirective { + control := inheritanceControl{ + path: node.path.Key(inheritanceKey), + locations: []Location{publicLocation(node.directive.Value.Location)}, + cancelled: node.cancelled, + } + result.controls = append(result.controls, control) + controlOrigins := directInheritanceOrigins(node.directive.Value, control.path, inputOrigins) + result.operations = append(result.operations, inheritanceOperation{ + control: &control, controlOrigin: controlOrigins[control.path.String()], + }) + } + if limits.nodes >= 0 { + remaining := limits.nodes - retained - 1 // the sibling key + within := inheritanceLowerBoundWithin(payload, remaining) + if node.hasParent { + within = inheritanceOverlayLowerBoundWithin(resolved[node.parent], payload, remaining) + } + if !within { + return nil, []Diagnostic{inheritanceBudgetDiagnostic(node.path, node.entry.Value.Location)} + } + } + + if node.hasParent { + parent := resolved[node.parent] + merged := document.Clone(parent) + transfer := inheritanceTransfer{ + from: graph.nodes[node.parent].path, to: node.path, + reference: node.path.Key(inheritanceKey), + locations: []Location{publicLocation(node.directive.Value.Location)}, + } + mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[node.parent], transfer.from, node.path, transfer) + mergeInheritanceOrigin(mergedOrigins, node.path, childOrigins[node.path.String()]) + mergeRecursiveMappings(merged, payload, node.path, new(mappingIndex), recursiveMergeHooks{ + added: func(childPath Path, incoming *document.Node) { + copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath) + }, + combined: func(childPath Path, _ *document.Node) { + mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()]) + }, + replaced: func(childPath Path, existing, incoming *document.Node) { + previous := displacedOrigins(existing, mergedOrigins, childPath) + result.replacements = append(result.replacements, inheritanceReplacement{ + path: childPath.clone(), before: existing, after: incoming, + previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]), + }) + deleteOriginSubtree(mergedOrigins, childPath, existing) + copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath) + }, + }) + merged.Location = node.entry.Value.Location + resolved[index], resolvedOrigins[index] = merged, mergedOrigins + result.transfers = append(result.transfers, transfer) + result.operations = append(result.operations, inheritanceOperation{ + transfer: &transfer, parent: parent, effective: merged, + parentOrigins: resolvedOrigins[node.parent], effectiveOrigins: mergedOrigins, + }) + } else { + resolved[index] = document.Clone(payload) + resolvedOrigins[index] = childOrigins + } + + count, _ := inheritanceLowerBoundCount(resolved[index], limits.nodes) + retained += 1 + count + for _, operation := range result.operations[operationStart:] { + if !counter.add(operation.parent) || !counter.add(operation.effective) { + return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)} + } + } + for _, replacement := range result.replacements[replacementStart:] { + if !counter.add(replacement.before) || !counter.add(replacement.after) { + return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(node.path, node.entry.Value.Location)} + } + } + } + + root := *graph.scope + root.Mapping = make([]document.Entry, len(graph.scope.Mapping)) + result.origins = directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: graph.scope.Location}, graph.scopePath, inputOrigins) + for i, entry := range graph.scope.Mapping { + root.Mapping[i] = entry + if index, found := graph.byName[entry.Key]; found { + root.Mapping[i].Value = resolved[index] + for pointer, origin := range resolvedOrigins[index] { + result.origins[pointer] = origin + } + continue + } + root.Mapping[i].Value = document.Clone(entry.Value) + for pointer, origin := range directInheritanceOrigins(entry.Value, graph.scopePath.Key(entry.Key), inputOrigins) { + result.origins[pointer] = origin + } + } + result.root = &root + if !inheritanceLowerBoundWithin(result.root, limits.nodes) { + return nil, []Diagnostic{inheritanceBudgetDiagnostic(graph.scopePath, graph.scope.Location)} + } + return result, nil +} diff --git a/internal/engine/inheritance_schedule_test.go b/internal/engine/inheritance_schedule_test.go new file mode 100644 index 0000000..9d6f382 --- /dev/null +++ b/internal/engine/inheritance_schedule_test.go @@ -0,0 +1,135 @@ +package engine + +import ( + "reflect" + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestInheritanceGraphSchedulesDirectRelationshipsAndAppliesTemplates(t *testing.T) { + root := parseCompositionDocument(t, "schedule", ` +child: + extends: base + own: child-value +base: + inherited: base-value +cleared: + extends: null + own: cleared-value +scalar: kept +`) + original := document.Clone(root) + graph, diagnostics := analyzeInheritanceGraph(root, Path{}) + if len(diagnostics) != 0 { + t.Fatalf("analyze diagnostics = %#v", diagnostics) + } + if len(graph.edges) != 1 { + t.Fatalf("edges = %#v, want one direct parent edge", graph.edges) + } + order, diagnostics := graph.schedule() + if len(diagnostics) != 0 { + t.Fatalf("schedule diagnostics = %#v", diagnostics) + } + orderNames := make([]string, len(order)) + for i, index := range order { + orderNames[i] = graph.nodes[index].name + } + if want := []string{"base", "child", "cleared"}; !reflect.DeepEqual(orderNames, want) { + t.Fatalf("mapping schedule = %v, want %v", orderNames, want) + } + + result, diagnostics := applyInheritanceTemplates(graph, order, defaultProjectionLimits(), nil) + if len(diagnostics) != 0 { + t.Fatalf("apply diagnostics = %#v", diagnostics) + } + if !reflect.DeepEqual(root, original) { + t.Fatal("applying templates mutated the input mapping") + } + if len(result.controls) != 2 || result.controls[0].path.String() != "/child/extends" || result.controls[0].cancelled || + result.controls[1].path.String() != "/cleared/extends" || !result.controls[1].cancelled { + t.Fatalf("controls = %#v, want scheduled ordinary and null relationships", result.controls) + } + if len(result.transfers) != 1 || result.transfers[0].from.String() != "/base" || result.transfers[0].to.String() != "/child" { + t.Fatalf("transfers = %#v, want the direct parent transfer", result.transfers) + } + if got, want := result.root.Mapping[0].Key, "child"; got != want { + t.Fatalf("root order begins with %q, want %q", got, want) + } + child := result.root.Mapping[0].Value + inherited, found := documentAtPath(child, Path{}.Key("inherited")) + if !found || inherited.Text != "base-value" { + t.Fatalf("inherited value = %#v, found %t", inherited, found) + } + childValue, found := documentAtPath(child, Path{}.Key("own")) + if !found || childValue.Text != "child-value" { + t.Fatalf("child value = %#v, found %t", childValue, found) + } + cleared := result.root.Mapping[2].Value + clearedValue, found := documentAtPath(cleared, Path{}.Key("own")) + if !found || clearedValue.Text != "cleared-value" { + t.Fatalf("null relationship value = %#v, found %t", clearedValue, found) + } + if _, found := inheritanceDirective(cleared); found { + t.Fatal("null relationship directive remained in the template") + } + + child.Mapping[0].Value.Text = "changed-result" + inherited, found = documentAtPath(root.Mapping[1].Value, Path{}.Key("inherited")) + if !found || inherited.Text != "base-value" { + t.Fatalf("mutating result changed input: inherited value = %#v, found %t", inherited, found) + } +} + +func TestInheritanceGraphReportsCycleAtAuthoredRelationship(t *testing.T) { + root := parseCompositionDocument(t, "cycle-source", `alpha: + extends: beta +beta: + extends: alpha +`) + graph, diagnostics := analyzeInheritanceGraph(root, Path{}) + if len(diagnostics) != 0 { + t.Fatalf("analyze diagnostics = %#v", diagnostics) + } + _, diagnostics = graph.schedule() + if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle { + t.Fatalf("cycle diagnostics = %#v", diagnostics) + } + diagnostic := diagnostics[0] + if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "cycle-source" { + t.Fatalf("cycle primary = %#v, want authored source", diagnostic.Primary) + } + if diagnostic.Primary.Line != 4 { + t.Fatalf("cycle primary line = %d, want line 4", diagnostic.Primary.Line) + } + if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "cycle-source" { + t.Fatalf("cycle related sites = %#v, want the referenced mapping", diagnostic.Related) + } + if diagnostic.Message != "sibling inheritance contains a cycle" { + t.Fatalf("cycle message exposes unexpected details: %q", diagnostic.Message) + } +} + +func TestInheritanceGraphValidatesRelationshipKinds(t *testing.T) { + tests := []struct { + name string + text string + code Code + }{ + {name: "missing sibling", text: "child:\n extends: absent\n", code: CodeInheritanceMissing}, + {name: "non-mapping sibling", text: "base: scalar\nchild:\n extends: base\n", code: CodeInheritanceInvalid}, + {name: "invalid relationship", text: "child:\n extends: 3\n", code: CodeInheritanceInvalid}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + root := parseCompositionDocument(t, "invalid", test.text) + _, diagnostics := analyzeInheritanceGraph(root, Path{}) + if len(diagnostics) != 1 || diagnostics[0].Code != test.code { + t.Fatalf("diagnostics = %#v, want code %q", diagnostics, test.code) + } + if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "invalid" { + t.Fatalf("primary site = %#v, want authored declaration", diagnostics[0].Primary) + } + }) + } +}