diff --git a/docs/config.md b/docs/config.md index f4fb16b..da60fa5 100644 --- a/docs/config.md +++ b/docs/config.md @@ -220,6 +220,13 @@ value of null cancels a relationship without looking up a parent. Other value types, missing or non-mapping parents, and cycles fail the load with value-free diagnostics. +Within each sibling scope, direct mapping definitions resolve by dependency +readiness. A parent is resolved before a child; among currently eligible +definitions, the lexically smallest canonical semantic pointer is selected, +including newly eligible definitions as soon as their parent completes. This +ordering governs inheritance operations and their history, controls, and +transfers. It does not reorder the mappings in the resulting configuration. + `extends` is reserved structural input across ordinary and layered loads, every source kind, imports, selected overlays, and explicit CLI overrides. It is consumed before typed decoding and need not appear in the application's Go diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 60347a2..789eddc 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -12,9 +12,9 @@ Implemented component owners: and sequence-item placement, definition-registered distinct-ID sequence composition within strict tiers, and immediate-parent-relative import placement within that assembly. Higher-priority sequences replace lower - sequences as whole values. The engine also owns destination-local sibling - inheritance before typed decoding, plus shared source-version checking with - sanitized declaration evidence. + sequences as whole values. The engine also owns dependency-ready, + destination-local sibling inheritance before typed decoding, plus shared + source-version checking with sanitized declaration evidence. It retains each source's effective placement alongside physical source identity and owns schema inspection, lifecycle coordination, callback auditing, @@ -67,4 +67,5 @@ post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-desti records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md) records the source-version decision. [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit -sequence placement and composition. +sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md) +records the shared dependency-ready ordering decision. diff --git a/internal/engine/inheritance.go b/internal/engine/inheritance.go index bacd0f8..1fa3e39 100644 --- a/internal/engine/inheritance.go +++ b/internal/engine/inheritance.go @@ -1,10 +1,6 @@ package engine -import ( - "slices" - - "gitea.maximumdirect.net/eric/ConfigKit/internal/document" -) +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" const inheritanceKey = "extends" @@ -66,13 +62,6 @@ type inheritanceContributor struct { trace *inheritanceTrace } -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. @@ -84,202 +73,6 @@ func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, li return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil) } -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, - 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)) - resolvedOrigins := make(map[string]map[string]Origin, len(byName)) - state := make(map[string]uint8, len(byName)) - result := &inheritanceScope{} - counter := newRetainedNodeCounter(historyNodeLimit) - if len(budget) != 0 { - counter = budget[0] - } - 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 - } - 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) - operationStart, replacementStart := len(result.operations), len(result.replacements) - childOrigins := directInheritanceOrigins(payload, path, inputOrigins) - if reference, found := inheritanceDirective(entry.Value); found { - control := inheritanceControl{ - path: path.Key(inheritanceKey), - locations: []Location{publicLocation(reference.Value.Location)}, - cancelled: reference.Value.Kind == document.Null, - } - controlOrigins := directInheritanceOrigins(reference.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 := 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) - control := inheritanceDirectiveLocation(entry.Value) - transfer := inheritanceTransfer{ - from: scopePath.Key(frame.parent), to: path, - reference: path.Key(inheritanceKey), locations: []Location{control}, - } - mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[frame.parent], transfer.from, path, transfer) - mergeInheritanceOrigin(mergedOrigins, path, childOrigins[path.String()]) - mergeRecursiveMappings(merged, payload, 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 = entry.Value.Location - resolved[frame.name] = merged - resolvedOrigins[frame.name] = mergedOrigins - result.transfers = append(result.transfers, transfer) - result.operations = append(result.operations, inheritanceOperation{ - transfer: &transfer, parent: parent, effective: merged, - parentOrigins: resolvedOrigins[frame.parent], effectiveOrigins: mergedOrigins, - }) - } else { - resolved[frame.name] = document.Clone(payload) - resolvedOrigins[frame.name] = childOrigins - } - count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes) - retained += 1 + count - for _, operation := range result.operations[operationStart:] { - if !counter.add(operation.parent) || !counter.add(operation.effective) { - return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)} - } - } - for _, replacement := range result.replacements[replacementStart:] { - if !counter.add(replacement.before) || !counter.add(replacement.after) { - return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)} - } - } - state[frame.name] = 2 - stack = stack[:len(stack)-1] - } - } - - root := *scope - root.Mapping = make([]document.Entry, len(scope.Mapping)) - rootOrigin := directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: scope.Location}, scopePath, inputOrigins) - result.origins = rootOrigin - for index, entry := range scope.Mapping { - root.Mapping[index] = entry - if resolved[entry.Key] != nil { - root.Mapping[index].Value = resolved[entry.Key] - for pointer, origin := range resolvedOrigins[entry.Key] { - result.origins[pointer] = origin - } - } else { - root.Mapping[index].Value = document.Clone(entry.Value) - for pointer, origin := range directInheritanceOrigins(entry.Value, scopePath.Key(entry.Key), inputOrigins) { - result.origins[pointer] = origin - } - } - } - result.root = &root - if !inheritanceLowerBoundWithin(result.root, limits.nodes) { - return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)} - } - return result, nil -} - func inheritanceDirective(node *document.Node) (document.Entry, bool) { for _, entry := range node.Mapping { if entry.Key == inheritanceKey { diff --git a/internal/engine/inheritance_schedule.go b/internal/engine/inheritance_schedule.go index 329392a..c4a0534 100644 --- a/internal/engine/inheritance_schedule.go +++ b/internal/engine/inheritance_schedule.go @@ -25,84 +25,6 @@ type inheritanceGraph struct { 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. @@ -197,15 +119,11 @@ func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, l if len(diagnostics) != 0 { return nil, diagnostics } - order, diagnostics := graph.depthFirstTemplateOrder() + order, diagnostics := graph.schedule() 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 + return applyInheritanceTemplates(graph, order, limits, inputOrigins, budget...) } // applyInheritanceTemplates builds detached templates in the supplied diff --git a/internal/engine/inheritance_schedule_test.go b/internal/engine/inheritance_schedule_test.go index 9d6f382..1eb57b9 100644 --- a/internal/engine/inheritance_schedule_test.go +++ b/internal/engine/inheritance_schedule_test.go @@ -110,6 +110,41 @@ beta: } } +func TestInheritanceExpansionInterleavesUnequalBranches(t *testing.T) { + root := parseCompositionDocument(t, "branches", `a-grandchild: + extends: z-child + own: grandchild +b-base: + base: b +c-base: + base: c +d-child: + extends: c-base + own: d +z-child: + extends: b-base + own: z +`) + result, diagnostics := expandInheritanceTree(root, defaultProjectionLimits()) + if len(diagnostics) != 0 { + t.Fatalf("expand diagnostics = %#v", diagnostics) + } + got := make([]string, len(result.transfers)) + for i, transfer := range result.transfers { + got[i] = transfer.from.String() + " -> " + transfer.to.String() + } + want := []string{"/c-base -> /d-child", "/b-base -> /z-child", "/z-child -> /a-grandchild"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("transfer order = %v, want %v", got, want) + } + if value, found := documentAtPath(result.root, Path{}.Key("a-grandchild").Key("base")); !found || value.Text != "b" { + t.Fatalf("grandchild inherited value = %#v, found %t", value, found) + } + if value, found := documentAtPath(result.root, Path{}.Key("d-child").Key("base")); !found || value.Text != "c" { + t.Fatalf("independent branch inherited value = %#v, found %t", value, found) + } +} + func TestInheritanceGraphValidatesRelationshipKinds(t *testing.T) { tests := []struct { name string