diff --git a/internal/engine/compose.go b/internal/engine/compose.go index a0c2401..b229281 100644 --- a/internal/engine/compose.go +++ b/internal/engine/compose.go @@ -8,6 +8,7 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" type composedDocument struct { root *document.Node acquisition []acquisitionScope + importGraph importInclusionGraph claims []compositionClaim sequenceRanges []sequenceRange origins map[string]Origin @@ -51,6 +52,7 @@ func cloneComposedDocument(base *composedDocument) *composedDocument { return &composedDocument{ root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims), acquisition: cloneAcquisitionScopes(base.acquisition), + importGraph: base.importGraph.clone(), sequenceRanges: cloneSequenceRanges(base.sequenceRanges), origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays), applications: cloneHistoryApplications(base.applications), diff --git a/internal/engine/import_graph.go b/internal/engine/import_graph.go new file mode 100644 index 0000000..2c71e55 --- /dev/null +++ b/internal/engine/import_graph.go @@ -0,0 +1,190 @@ +package engine + +import ( + "fmt" + "path/filepath" + "strings" +) + +type importNodeKey struct { + namespaceKind string + namespaceAnchor string + resourceKind string + resourcePath string +} + +func (key importNodeKey) dependencyKey() dependencyKey { + return dependencyKey{key.namespaceKind, key.namespaceAnchor, key.resourceKind, key.resourcePath} +} + +type importContributionRole string + +const ( + importRoleMain importContributionRole = "main" + importRoleMapping importContributionRole = "mapping-import" + importRoleItem importContributionRole = "item-import" +) + +type importGraphNode struct { + key importNodeKey + canonical string + sourceID string + role importContributionRole +} + +type importInclusionEdge struct { + importer importNodeKey + imported importNodeKey + location Location +} + +// importInclusionGraph retains source inclusion independently of mapping-scope +// ownership. Scheduling edges point from each imported prerequisite to its +// importer, matching the shared dependency scheduler's convention. +type importInclusionGraph struct { + nodes []importGraphNode + edges []importInclusionEdge + index map[importNodeKey]int +} + +func newImportInclusionGraph() importInclusionGraph { + return importInclusionGraph{index: make(map[importNodeKey]int)} +} + +func (graph *importInclusionGraph) addNode(node importGraphNode) int { + if graph.index == nil { + graph.index = make(map[importNodeKey]int) + } + if index, exists := graph.index[node.key]; exists { + return index + } + index := len(graph.nodes) + graph.index[node.key] = index + graph.nodes = append(graph.nodes, node) + return index +} + +func (graph *importInclusionGraph) addEdge(importer, imported importNodeKey, location Location) int { + index := len(graph.edges) + graph.edges = append(graph.edges, importInclusionEdge{ + importer: importer, imported: imported, location: location, + }) + return index +} + +func (graph importInclusionGraph) clone() importInclusionGraph { + cloned := importInclusionGraph{ + nodes: append([]importGraphNode(nil), graph.nodes...), + edges: make([]importInclusionEdge, len(graph.edges)), + index: make(map[importNodeKey]int, len(graph.index)), + } + for index, edge := range graph.edges { + cloned.edges[index] = importInclusionEdge{ + importer: edge.importer, imported: edge.imported, location: edge.location, + } + } + for key, index := range graph.index { + cloned.index[key] = index + } + return cloned +} + +func (graph importInclusionGraph) scheduleCycle(closingEdge int, location Location) (Diagnostic, bool) { + keys := make([]dependencyKey, len(graph.nodes)) + for index, node := range graph.nodes { + keys[index] = node.key.dependencyKey() + } + edges := make([]dependencyEdge, 0, len(graph.edges)) + for _, edge := range graph.edges { + importer, importerOK := graph.index[edge.importer] + imported, importedOK := graph.index[edge.imported] + if !importerOK || !importedOK { + continue + } + edges = append(edges, dependencyEdge{prerequisite: imported, dependent: importer}) + } + _, cycle := scheduleDependencies(keys, edges) + if len(cycle) < 2 { + return Diagnostic{}, false + } + + // Scheduler edges reverse inclusion edges. Reverse its closed cycle to + // render the authored import route while keeping the canonical start node. + route := make([]int, 0, len(cycle)) + route = append(route, cycle[0]) + for index := len(cycle) - 2; index > 0; index-- { + route = append(route, cycle[index]) + } + route = append(route, cycle[0]) + paths := make([]string, len(route)) + for index, node := range route { + paths[index] = graph.nodes[node].canonical + } + + related := make([]Location, 0, len(route)-2) + for index := 0; index+1 < len(route); index++ { + importerKey := graph.nodes[route[index]].key + importedKey := graph.nodes[route[index+1]].key + edgeIndex := -1 + for candidate, edge := range graph.edges { + if edge.importer == importerKey && edge.imported == importedKey { + edgeIndex = candidate + if candidate == closingEdge { + break + } + } + } + if edgeIndex == closingEdge { + continue + } + if edgeIndex >= 0 { + related = append(related, graph.edges[edgeIndex].location) + } + } + return Diagnostic{ + Code: CodeImportCycle, Severity: SeverityError, + Message: "import cycle detected: " + strings.Join(paths, " -> "), + Primary: cloneLocation(&location), Related: related, + }, true +} + +func importKeyFor(access *filesystemAccess, canonical string, synthetic bool) importNodeKey { + kind := "host" + resourceKind := "file" + resourcePath := filepath.ToSlash(canonical) + if access.native != nil { + kind = "native" + } + if synthetic { + resourceKind = "synthetic" + resourcePath = "" + } + return importNodeKey{ + namespaceKind: kind, + // This traversal owns the main input position; grouped inputs receive + // their explicit positions when their acquisition graphs are combined. + namespaceAnchor: fmt.Sprintf("%020d/%020d", 0, 0), + resourceKind: resourceKind, resourcePath: resourcePath, + } +} + +func importKeyForMainSource(source *SourceInfo) importNodeKey { + if source == nil { + return importNodeKey{} + } + namespaceKind := "host" + if source.Kind == SourceKindFilesystem { + namespaceKind = "native" + } + synthetic := source.Kind != SourceKindFile && source.Kind != SourceKindFilesystem + resourceKind := "file" + resourcePath := filepath.ToSlash(source.Path) + if synthetic { + resourceKind = "synthetic" + resourcePath = "" + } + return importNodeKey{ + namespaceKind: namespaceKind, namespaceAnchor: fmt.Sprintf("%020d/%020d", 0, 0), + resourceKind: resourceKind, resourcePath: resourcePath, + } +} diff --git a/internal/engine/imports.go b/internal/engine/imports.go index e2e70ea..ff5700e 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -35,6 +35,7 @@ func defaultImportLimits() importLimits { type importRoute struct { canonical string locations []Location + key importNodeKey } type importAccounting struct { @@ -59,6 +60,7 @@ type importTraversal struct { active map[resourceIdentity]int visited map[resourceIdentity]importRoute stack []importRoute + importGraph importInclusionGraph } func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { @@ -139,11 +141,12 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, if mainPath != "" { mainInfo.Path = mainPath } - mainKey := access.identity(mainPath) + mainResource := access.identity(mainPath) + mainNodeKey := importKeyFor(access, mainPath, mainPath == "") if mainPath == "" { - mainKey = access.syntheticIdentity() + mainResource = access.syntheticIdentity() } - if previous, repeated := visited[mainKey]; repeated { + if previous, repeated := visited[mainResource]; repeated { _ = access.close() return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})} } @@ -152,9 +155,10 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, accounting.nodes += countInputNodes(main.document) state := importTraversal{ access: access, accounting: accounting, limits: limits, rule: rule, versionState: versionState, - scope: acquisitionScope{destination: destination.clone()}, + scope: acquisitionScope{owner: mainNodeKey, destination: destination.clone()}, sources: []SourceInfo{mainInfo}, active: make(map[resourceIdentity]int), visited: visited, + importGraph: newImportInclusionGraph(), } if mainIsItem { state.itemScope = true @@ -176,8 +180,10 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, mainIdentity = "" } mainRoute := importRoute{canonical: mainIdentity} - state.active[mainKey] = 0 - state.visited[mainKey] = mainRoute + state.importGraph.addNode(importGraphNode{key: mainNodeKey, canonical: mainIdentity, sourceID: mainInfo.ID, role: importRoleMain}) + mainRoute.key = mainNodeKey + state.active[mainResource] = 0 + state.visited[mainResource] = mainRoute state.stack = append(state.stack, mainRoute) var body *document.Node var declarations []importDeclaration @@ -191,7 +197,8 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, if len(diagnostics) == 0 { if mainIsItem { state.scope.contributions = append(state.scope.contributions, acquiredContribution{ - source: cloneSourceInfo(mainInfo), payload: body, document: body, processing: processing, + source: cloneSourceInfo(mainInfo), identity: mainNodeKey, role: importRoleMain, + scopeOwner: mainNodeKey, payload: body, document: body, processing: processing, }) diagnostics = state.walkDeclarations(basePath, declarations, 0, Path{}) } else { @@ -205,12 +212,16 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, accounting.nodes += placementNodes state.scope.contributions = append(state.scope.contributions, acquiredContribution{ source: cloneSourceInfo(mainInfo), payload: body, document: placed, + identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(), processing: processing, }) diagnostics = state.walkDeclarations(basePath, declarations, 0, destination) } } } + if len(diagnostics) == 0 { + diagnostics = state.composePendingItems(&state.scope) + } closeErr := access.close() if len(diagnostics) != 0 { @@ -267,13 +278,16 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, if composed != nil { synthetic := acquisitionScope{contributions: []acquiredContribution{{ source: cloneSourceInfo(mainInfo), payload: completed.root, document: placed, + identity: mainNodeKey, role: importRoleMain, scopeOwner: mainNodeKey, destination: destination.clone(), processing: processing, nested: completed, }}} + synthetic.owner = mainNodeKey projectNestedEvidence(composed, synthetic) projectAcquiredDestinations(composed, synthetic, state.sources) composed.acquisition = []acquisitionScope{cloneAcquisitionScope(synthetic)} composed.inputBytes = accounting.bytes composed.inputNodes = accounting.nodes + composed.importGraph = state.importGraph.clone() } return composed, cloneSourceInfos(state.sources), failures } @@ -293,6 +307,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, composed.acquisition = []acquisitionScope{cloneAcquisitionScope(state.scope)} composed.inputBytes = accounting.bytes composed.inputNodes = accounting.nodes + composed.importGraph = state.importGraph.clone() } return composed, cloneSourceInfos(state.sources), diagnostics } @@ -386,11 +401,18 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati currentRoute := s.currentRoute(location) identity := s.access.identity(canonical) + nodeKey := importKeyFor(s.access, canonical, false) + importerKey := s.currentImportNode() if activeIndex, ok := s.active[identity]; ok { + closingEdge := s.importGraph.addEdge(importerKey, nodeKey, location) + if diagnostic, ok := s.importGraph.scheduleCycle(closingEdge, location); ok { + return []Diagnostic{diagnostic} + } cycle := append([]importRoute(nil), s.stack[activeIndex:]...) return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)} } if previous, ok := s.visited[identity]; ok { + s.importGraph.addEdge(importerKey, nodeKey, location) return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)} } if s.accounting.sources+1 > s.limits.sources { @@ -467,12 +489,18 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati if declaration.placement == placementModeSequenceItem { mode = placementModeSequenceItem } + role := importRoleMapping if mode == placementModeSequenceItem { - route := importRoute{canonical: canonical, locations: currentRoute.locations} + role = importRoleItem + } + s.importGraph.addNode(importGraphNode{key: nodeKey, canonical: canonical, sourceID: sourceID, role: role}) + s.importGraph.addEdge(importerKey, nodeKey, location) + if mode == placementModeSequenceItem { + route := importRoute{canonical: canonical, locations: currentRoute.locations, key: nodeKey} s.active[identity] = len(s.stack) s.visited[identity] = route s.stack = append(s.stack, route) - diagnostics := s.visitItemSource(canonical, declaration, destination, relativeDestination, depth, info, body, declarations, processing) + diagnostics := s.visitItemSource(canonical, nodeKey, declaration, destination, relativeDestination, depth, info, body, declarations, processing) delete(s.active, identity) s.stack = s.stack[:len(s.stack)-1] return diagnostics @@ -486,9 +514,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)} } s.accounting.nodes += placementNodes - route := importRoute{canonical: canonical, locations: currentRoute.locations} + route := importRoute{canonical: canonical, locations: currentRoute.locations, key: nodeKey} s.scope.contributions = append(s.scope.contributions, acquiredContribution{ source: cloneSourceInfo(info), payload: body, document: placed, + identity: nodeKey, role: role, scopeOwner: s.scope.owner, destination: placementDestination.clone(), relativeDestination: relativeDestination.clone(), processing: processing, }) s.active[identity] = len(s.stack) @@ -500,6 +529,17 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati return diagnostics } +func (s *importTraversal) currentImportNode() importNodeKey { + if len(s.stack) != 0 { + return s.stack[len(s.stack)-1].key + } + key := s.scope.owner + if _, exists := s.importGraph.index[key]; !exists { + s.importGraph.addNode(importGraphNode{key: key, canonical: "", role: importRoleMain}) + } + return key +} + func (s *importTraversal) sourceDestination(destination Path) Path { if !s.itemScope { return destination.clone() @@ -509,10 +549,10 @@ func (s *importTraversal) sourceDestination(destination Path) Path { return s.itemFallback.clone() } -func (s *importTraversal) visitItemSource(canonical string, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic { +func (s *importTraversal) visitItemSource(canonical string, identity importNodeKey, declaration importDeclaration, destination, relativeDestination Path, depth int, info SourceInfo, body *document.Node, declarations []importDeclaration, processing *sourceVersionProcessingResult) []Diagnostic { parentScope := s.scope parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback - s.scope = acquisitionScope{} + s.scope = acquisitionScope{owner: identity} s.itemScope = true if parentItemScope { s.itemPrefix = appendDestination(parentPrefix, destination) @@ -522,6 +562,7 @@ func (s *importTraversal) visitItemSource(canonical string, declaration importDe } s.scope.contributions = append(s.scope.contributions, acquiredContribution{ source: cloneSourceInfo(info), payload: body, document: body, + identity: identity, role: importRoleItem, scopeOwner: identity, processing: processing, }) diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{}) @@ -530,35 +571,53 @@ func (s *importTraversal) visitItemSource(canonical string, declaration importDe if len(diagnostics) != 0 { return projectItemDiagnosticPaths(diagnostics, parentScope, destination) } - documents := make([]*document.Node, 0, len(itemScope.contributions)) - for _, contribution := range itemScope.contributions { - documents = append(documents, contribution.document) - } - completed, diagnostics := composeDocuments(documents...) - if len(diagnostics) != 0 { - return projectItemDiagnosticPaths(diagnostics, parentScope, destination) - } itemScope.destination = Path{} - completed.acquisition = []acquisitionScope{cloneAcquisitionScope(itemScope)} - projectNestedEvidence(completed, itemScope) - pl := destination - _, placed, err := placeDocumentWithMode(completed.root, pl, placementModeSequenceItem, body.Location, maxDocumentDepth, maxDocumentNodes) - if err != nil { - return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &declaration.location)} - } - placementNodes := destinationWrapperNodeCountForMode(pl, placementModeSequenceItem) - if s.accounting.nodes+placementNodes > s.limits.nodes { - return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &declaration.location)} - } - s.accounting.nodes += placementNodes s.scope.contributions = append(s.scope.contributions, acquiredContribution{ - source: cloneSourceInfo(info), payload: completed.root, document: placed, - destination: pl.clone(), relativeDestination: relativeDestination.clone(), processing: processing, - nested: completed, + source: cloneSourceInfo(info), payload: body, + identity: identity, role: importRoleItem, scopeOwner: parentScope.owner, + destination: destination.clone(), relativeDestination: relativeDestination.clone(), processing: processing, + pendingItem: &itemScope, }) return nil } +func (s *importTraversal) composePendingItems(scope *acquisitionScope) []Diagnostic { + for index := range scope.contributions { + contribution := &scope.contributions[index] + if contribution.pendingItem == nil { + continue + } + itemScope := contribution.pendingItem + if diagnostics := s.composePendingItems(itemScope); len(diagnostics) != 0 { + return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination) + } + documents := make([]*document.Node, 0, len(itemScope.contributions)) + for _, nested := range itemScope.contributions { + documents = append(documents, nested.document) + } + completed, diagnostics := composeDocuments(documents...) + if len(diagnostics) != 0 { + return projectItemDiagnosticPaths(diagnostics, *scope, contribution.destination) + } + completed.acquisition = []acquisitionScope{cloneAcquisitionScope(*itemScope)} + projectNestedEvidence(completed, *itemScope) + _, placed, err := placeDocumentWithMode(completed.root, contribution.destination, placementModeSequenceItem, contribution.payload.Location, maxDocumentDepth, maxDocumentNodes) + if err != nil { + return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", contribution.source.ImportedFrom)} + } + placementNodes := destinationWrapperNodeCountForMode(contribution.destination, placementModeSequenceItem) + if s.accounting.nodes+placementNodes > s.limits.nodes { + return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", contribution.source.ImportedFrom)} + } + s.accounting.nodes += placementNodes + contribution.payload = completed.root + contribution.document = placed + contribution.nested = completed + contribution.pendingItem = nil + } + return nil +} + func importedVersionClaim(documents []*document.Node, composed *composedDocument, rule *VersionRule, mainIndex int) *Diagnostic { if composed == nil || rule == nil { return nil diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 953ff97..7f1eaf4 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -656,6 +656,22 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) { } }) + t.Run("cycle through an item scope", func(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: item.yml + destination: [items] + placement: sequence-item +`) + writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: main.yml}]}\n") + loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle) + diagnostic := loadError.Diagnostics()[0] + if diagnostic.Primary == nil || len(diagnostic.Related) != 1 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "item.yml") { + t.Fatalf("nested item cycle diagnostic = %#v", diagnostic) + } + }) + t.Run("diamond", func(t *testing.T) { directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n") @@ -686,6 +702,61 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) { }) } +func TestItemAcquisitionRetainsInclusionGraphAndScopeOwnership(t *testing.T) { + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: item.yml + destination: [items] + placement: sequence-item +`) + itemPath := writeImportFile(t, directory, "item.yml", "configkit: {imports: [{path: helper.yml}]}\nname: item\n") + helperPath := writeImportFile(t, directory, "helper.yml", "id: supplied\n") + + input, diagnostics := assembleBaseInput[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true}) + if len(diagnostics) != 0 { + t.Fatalf("assemble input: %#v", diagnostics) + } + graph := input.composition.importGraph + if len(graph.nodes) != 3 || len(graph.edges) != 2 { + t.Fatalf("inclusion graph has %d nodes and %d edges, want 3 and 2", len(graph.nodes), len(graph.edges)) + } + for index, want := range []struct { + path string + role importContributionRole + }{ + {path: canonicalTestPath(t, mainPath), role: importRoleMain}, + {path: canonicalTestPath(t, itemPath), role: importRoleItem}, + {path: canonicalTestPath(t, helperPath), role: importRoleMapping}, + } { + node := graph.nodes[index] + if node.canonical != want.path || node.role != want.role || node.sourceID != "source-"+strconv.Itoa(index+1) { + t.Errorf("graph node %d = %#v, want %s, %q, source-%d", index, node, want.role, want.path, index+1) + } + } + rootScope := input.composition.acquisition[0] + if rootScope.owner != graph.nodes[0].key || len(rootScope.contributions) != 2 { + t.Fatalf("root acquisition scope = %#v", rootScope) + } + item := rootScope.contributions[1] + if item.identity != graph.nodes[1].key || item.role != importRoleItem || item.scopeOwner != rootScope.owner || item.nested == nil { + t.Fatalf("outer item contribution = %#v", item) + } + itemScope := item.nested.acquisition[0] + if itemScope.owner != item.identity || len(itemScope.contributions) != 2 { + t.Fatalf("nested item scope = %#v", itemScope) + } + for _, contribution := range itemScope.contributions { + if contribution.scopeOwner != item.identity { + t.Errorf("nested contribution scope owner = %#v, want item %v", contribution.scopeOwner, item.identity) + } + } + if graph.edges[0].importer != graph.nodes[0].key || graph.edges[0].imported != graph.nodes[1].key || graph.edges[0].location.SourceID != "source-1" || + graph.edges[1].importer != graph.nodes[1].key || graph.edges[1].imported != graph.nodes[2].key || graph.edges[1].location.SourceID != "source-2" { + t.Fatalf("inclusion edges = %#v", graph.edges) + } +} + func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) { if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { t.Skip("initial filesystem contract covers Linux and macOS") diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 557fcdf..dfb38a7 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -23,37 +23,51 @@ type assembledInput struct { // boundary for later scoped assembly and evidence. type acquiredContribution struct { source SourceInfo + identity importNodeKey + role importContributionRole + scopeOwner importNodeKey payload *document.Node document *document.Node destination Path relativeDestination Path processing *sourceVersionProcessingResult nested *composedDocument + pendingItem *acquisitionScope } // acquisitionScope is the ordered strict mapping scope produced by one input // and its ordinary imports. type acquisitionScope struct { + owner importNodeKey destination Path contributions []acquiredContribution } func cloneAcquisitionScope(scope acquisitionScope) acquisitionScope { - cloned := acquisitionScope{destination: scope.destination.clone()} + cloned := acquisitionScope{owner: scope.owner, 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, + source: cloneSourceInfo(contribution.source), identity: contribution.identity, + role: contribution.role, scopeOwner: contribution.scopeOwner, payload: contribution.payload, document: contribution.document, destination: contribution.destination.clone(), relativeDestination: contribution.relativeDestination.clone(), processing: contribution.processing, - nested: cloneComposedDocument(contribution.nested), + nested: cloneComposedDocument(contribution.nested), pendingItem: cloneAcquisitionScopePointer(contribution.pendingItem), } } } return cloned } +func cloneAcquisitionScopePointer(scope *acquisitionScope) *acquisitionScope { + if scope == nil { + return nil + } + cloned := cloneAcquisitionScope(*scope) + return &cloned +} + func cloneAcquisitionScopes(scopes []acquisitionScope) []acquisitionScope { if scopes == nil { return nil @@ -145,10 +159,13 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d } localPayload := input.document input.document = placed + identity := importKeyForMainSource(parsed.source) input.acquisition = []acquisitionScope{{ + owner: identity, destination: destination.clone(), contributions: []acquiredContribution{{ source: cloneSourceInfo(*parsed.source), payload: localPayload, + identity: identity, role: importRoleMain, scopeOwner: identity, document: placed, destination: destination.clone(), processing: versionProcessing, }}, }}