diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9b70a5a..a16d77f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Implementation Plan: Sequence Placement and Composition -Status: implementation in progress; stages 1–5 completed. +Status: implementation in progress; stages 1–6 completed. ## Purpose and authority @@ -414,6 +414,20 @@ Scope: `imports.go`, `load_input.go`, `load_request.go`, and their handoffs. Validation: import/destination/grouped/source-version/resource-context tests and `./scripts/check.sh`. Completion: this is a behavior-preserving private refactor. +Completion evidence: acquisition now retains ordered ordinary-mapping scopes +with source-local payloads, placed contributions, destinations, source records, +and source-version processing results. Underlay groups compose those acquired +contributions at their boundary instead of treating retained history values as +complete roots. Existing filesystem contexts, import traversal/repetition +state, load-wide accounting, hooks, and lifecycle handling remain in their +existing paths. These checks passed with Go `go1.26.9 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test ./internal/engine -run 'Destination|Import|Grouped|SourceVersion|Resource' -count=1` +- `GOTOOLCHAIN=local ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 7 — Add the private item-placement operation and accounting Scope: placement primitives and engine per-input options; not public exposure. diff --git a/internal/engine/compose.go b/internal/engine/compose.go index b079a32..1cf4b4d 100644 --- a/internal/engine/compose.go +++ b/internal/engine/compose.go @@ -7,6 +7,7 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" // origins retain every source that contributes to a composite mapping. type composedDocument struct { root *document.Node + acquisition []acquisitionScope claims []compositionClaim sequenceRanges []sequenceRange origins map[string]Origin @@ -46,6 +47,7 @@ func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator { func cloneComposedDocument(base *composedDocument) *composedDocument { return &composedDocument{ root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), + acquisition: cloneAcquisitionScopes(base.acquisition), sequenceRanges: cloneSequenceRanges(base.sequenceRanges), origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), applications: cloneHistoryApplications(base.applications), diff --git a/internal/engine/destination_import_test.go b/internal/engine/destination_import_test.go index da51370..2a1de96 100644 --- a/internal/engine/destination_import_test.go +++ b/internal/engine/destination_import_test.go @@ -4,6 +4,8 @@ import ( "path/filepath" "testing" "testing/fstest" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" ) type importDestinationConfig struct { @@ -133,7 +135,11 @@ parent: true } } - composed, diagnostics := composeDocuments(state.documents...) + documents := make([]*document.Node, 0, len(state.scope.contributions)) + for _, contribution := range state.scope.contributions { + documents = append(documents, contribution.document) + } + composed, diagnostics := composeDocuments(documents...) if len(diagnostics) != 0 { t.Fatalf("composition diagnostics = %#v", diagnostics) } diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 8ed0907..06af12e 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -51,11 +51,11 @@ type importTraversal struct { rule *VersionRule versionState *sourceVersionState - documents []*document.Node - sources []SourceInfo - active map[resourceIdentity]int - visited map[resourceIdentity]importRoute - stack []importRoute + scope acquisitionScope + sources []SourceInfo + active map[resourceIdentity]int + visited map[resourceIdentity]importRoute + stack []importRoute } func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { @@ -144,9 +144,9 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa accounting.nodes += countInputNodes(main.document) state := importTraversal{ access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState, - documents: make([]*document.Node, 0, 1), - sources: []SourceInfo{mainInfo}, - active: make(map[resourceIdentity]int), visited: visited, + scope: acquisitionScope{destination: destination.clone()}, + sources: []SourceInfo{mainInfo}, + active: make(map[resourceIdentity]int), visited: visited, } if state.visited == nil { state.visited = make(map[resourceIdentity]importRoute) @@ -182,7 +182,10 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)} } else { accounting.nodes += placementNodes - state.documents = append(state.documents, placed) + state.scope.contributions = append(state.scope.contributions, acquiredContribution{ + source: cloneSourceInfo(mainInfo), payload: body, document: placed, + destination: destination.clone(), processing: processing, + }) diagnostics = state.walkDeclarations(basePath, declarations, 0, destination) } } @@ -194,8 +197,13 @@ func loadImportedWithAccessTrackedAndProcessing(access *filesystemAccess, basePa if closeErr != nil { return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)} } - composed, diagnostics := composeDocuments(state.documents...) + documents := make([]*document.Node, 0, len(state.scope.contributions)) + for _, contribution := range state.scope.contributions { + documents = append(documents, contribution.document) + } + composed, diagnostics := composeDocuments(documents...) if composed != nil { + composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)} composed.inputBytes = accounting.bytes composed.inputNodes = accounting.nodes } @@ -237,20 +245,20 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor matched := declaration matched.path = match matched.glob = "" - if diagnostics := s.visitPath(basePath, matched, childDestination, depth+1); len(diagnostics) != 0 { + if diagnostics := s.visitPath(basePath, matched, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 { return diagnostics } } continue } - if diagnostics := s.visitPath(basePath, declaration, childDestination, depth+1); len(diagnostics) != 0 { + if diagnostics := s.visitPath(basePath, declaration, childDestination, declaration.destination, depth+1); len(diagnostics) != 0 { return diagnostics } } return nil } -func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination Path, depth int) []Diagnostic { +func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, destination, relativeDestination Path, depth int) []Diagnostic { location := declaration.location if depth > s.limits.depth { return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)} @@ -334,11 +342,13 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati var body *document.Node var declarations []importDeclaration var diagnostics []Diagnostic + var processing *sourceVersionProcessingResult if s.versionState != nil && s.versionState.mode != sourceVersionModeDefault { processed, failures := s.versionState.process(root, sourceVersionContribution, destination, sourceID) if len(failures) != 0 { return failures } + processing = &processed body, declarations = processed.payload, processed.imports } else { body, declarations, diagnostics = splitImportDirectives(root) @@ -368,7 +378,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati } route := importRoute{canonical: canonical, locations: currentRoute.locations} - s.documents = append(s.documents, placed) + s.scope.contributions = append(s.scope.contributions, acquiredContribution{ + source: cloneSourceInfo(info), payload: body, document: placed, + destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: processing, + }) s.active[identity] = len(s.stack) s.visited[identity] = route s.stack = append(s.stack, route) diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index a8b702f..fc818d2 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -11,12 +11,58 @@ type assembledInput struct { parsed parsedSource document *document.Node composition *composedDocument + acquisition []acquisitionScope inheritance *inheritanceScope sources []SourceInfo contexts map[string]ResourceContext versionState *sourceVersionState } +// acquiredContribution keeps a source-local payload beside its placed document. +// The latter is used for composition; the former preserves the acquisition +// boundary for later scoped assembly and evidence. +type acquiredContribution struct { + source SourceInfo + payload *document.Node + document *document.Node + destination Path + relativeDestination Path + processing *sourceVersionProcessingResult +} + +// acquisitionScope is the ordered strict mapping scope produced by one input +// and its ordinary imports. +type acquisitionScope struct { + destination Path + contributions []acquiredContribution +} + +func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope { + cloned := acquisitionScope{destination: scope.destination.clone()} + if scope.contributions != nil { + cloned.contributions = make([]acquiredContribution, len(scope.contributions)) + for index, contribution := range scope.contributions { + cloned.contributions[index] = acquiredContribution{ + source: cloneSourceInfo(contribution.source), payload: contribution.payload, + document: contribution.document, destination: contribution.destination.clone(), + relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing, + } + } + } + return cloned +} + +func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope { + if scopes == nil { + return nil + } + cloned := make([]acquisitionScope, len(scopes)) + for index, scope := range scopes { + cloned[index] = cloneAcquisitionScope(scope) + } + return cloned +} + func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { return assembleBaseInputTracked(source, options, Path{}, &importAccounting{}, nil, nil) } @@ -76,6 +122,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d if len(diagnostics) != 0 { return input, diagnostics } + input.acquisition = cloneAcquisitionScopes(input.composition.acquisition) input.document = input.composition.root } else { input.sources = sourceInfos(parsed.source) @@ -93,7 +140,15 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d if err != nil { return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")} } + localPayload := input.document input.document = placed + input.acquisition = []acquisitionScope{{ + destination: destination.clone(), + contributions: []acquiredContribution{{ + source: cloneSourceInfo(*parsed.source), payload: localPayload, + document: placed, destination: destination.clone(), processing: versionProcessing, + }}, + }} rawNodes := countInputNodes(parsed.document) placementNodes := destinationWrapperNodeCount(destination) if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+rawNodes+placementNodes > limits.nodes { @@ -185,5 +240,6 @@ func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) } composition.inputBytes = input.parsed.bytes composition.inputNodes = countInputNodes(input.document) + composition.acquisition = cloneAcquisitionScopes(input.acquisition) return composition, nil } diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index 1865dcb..d841550 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -161,7 +161,7 @@ func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRu initialBytes, initialNodes := accounting.bytes, accounting.nodes visited := make(map[resourceIdentity]importRoute) hostToken := &namespaceToken{} - var documents []*document.Node + var scopes []acquisitionScope var sources []SourceInfo var contexts map[string]ResourceContext for _, contribution := range group.inputs { @@ -186,12 +186,12 @@ func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRu Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location, }} } - if acquired.composition != nil { - for _, application := range acquired.composition.applications { - documents = append(documents, application.value) - } - } else { - documents = append(documents, acquired.document) + scopes = append(scopes, cloneAcquisitionScopes(acquired.acquisition)...) + } + var documents []*document.Node + for _, scope := range scopes { + for _, contribution := range scope.contributions { + documents = append(documents, contribution.document) } } if len(documents) == 0 { @@ -203,6 +203,7 @@ func acquireUnderlayGroupWithVersion(group loadGroup, index int, rule *VersionRu } composition.inputBytes = accounting.bytes - initialBytes composition.inputNodes = accounting.nodes - initialNodes + composition.acquisition = cloneAcquisitionScopes(scopes) for pointer, origin := range composition.origins { origin.Layer = LayerUnderlay composition.origins[pointer] = origin