From c71faf2065f1d39ff7451b49bc7234f99dec97ab Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 14 Oct 2026 13:31:32 +0000 Subject: [PATCH] Count destination wrappers in shared input limits --- docs/roadmap/implementation.md | 16 +- internal/engine/destination.go | 7 +- .../engine/destination_accounting_test.go | 176 ++++++++++++++++++ internal/engine/imports.go | 9 + internal/engine/load_input.go | 6 +- 5 files changed, 208 insertions(+), 6 deletions(-) create mode 100644 internal/engine/destination_accounting_test.go diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 631bdbe..4b0b416 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -312,7 +312,7 @@ Done: diagnostics consistently distinguish application paths from source syntax. ## Stage 7 — Complete shared placement accounting -Status: planned. Scope: resource bounds with no new public limit system. +Status: complete. Scope: resource bounds with no new public limit system. 1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary acquisition and import traversal, using `importAccounting` and current limit @@ -327,8 +327,18 @@ Status: planned. Scope: resource bounds with no new public limit system. empty mappings. Verify root placement and NoSource do not add charges. Preserve history/expansion limits; do not turn fixture values into defaults. -Validate: destination/limit/import/override tests and everyday check. Done: -placement cannot bypass current budgets and introduces no unrelated caps. +Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'` +and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in +`TestDestinationPlacementCountsAgainstSharedNodeBudget`, +`TestMultipleMountedInputsSharePlacementCharges`, +`TestImportedEmptyBodiesChargeEachDestinationWrapper`, +`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and +`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and +above the boundary, multiple shallow inputs, imported empty bodies, and charging +of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues +to cover per-document depth/node boundaries; existing grouped accounting tests +cover unchanged raw source/byte charging. Done: placement cannot bypass current +budgets and introduces no unrelated caps. ## Stage 8 — Preserve provenance, history, and source resource contexts diff --git a/internal/engine/destination.go b/internal/engine/destination.go index 6ca0fc3..8bf791b 100644 --- a/internal/engine/destination.go +++ b/internal/engine/destination.go @@ -86,6 +86,7 @@ func destinationWithinLimits(body *document.Node, destination Path, maxDepth, ma if segments > maxDepth || segments > maxNodes/2 { return false } + wrapperNodes := destinationWrapperNodeCount(destination) for _, segment := range destination.segments { if segment.kind != pathSegmentKey { return false @@ -94,7 +95,11 @@ func destinationWithinLimits(body *document.Node, destination Path, maxDepth, ma if documentDepth(body) > maxDepth-segments { return false } - return countInputNodes(body) <= maxNodes-2*segments + return countInputNodes(body) <= maxNodes-wrapperNodes +} + +func destinationWrapperNodeCount(destination Path) int { + return 2 * len(destination.segments) } func documentDepth(node *document.Node) int { diff --git a/internal/engine/destination_accounting_test.go b/internal/engine/destination_accounting_test.go new file mode 100644 index 0000000..7bece4e --- /dev/null +++ b/internal/engine/destination_accounting_test.go @@ -0,0 +1,176 @@ +package engine + +import ( + "testing" + "testing/fstest" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) + +func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) { + t.Parallel() + + limits := defaultImportLimits() + destination := Keys("collections", "entry") + empty := Mapping("empty", map[string]any{}) + parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination) + + for _, test := range []struct { + name string + delta int + wantError bool + wantCharge int + }{ + {name: "below aggregate bound", delta: -1, wantCharge: perInput}, + {name: "at aggregate bound", delta: 0, wantCharge: perInput}, + {name: "above aggregate bound", delta: 1, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta} + composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{ + source: empty, destination: destination, + }}}, 1, nil, accounting) + if test.wantError { + if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded { + t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics) + } + return + } + if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge { + t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics) + } + }) + } +} + +func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) { + t.Parallel() + + limits := defaultImportLimits() + inputs := []loadInput{ + {source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")}, + {source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")}, + {source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")}, + } + perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one")) + charge := len(inputs) * perInput + accounting := &importAccounting{nodes: limits.nodes - charge} + composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting) + if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge { + t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge) + } +} + +func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) { + t.Parallel() + + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")}, + "fragment.yml": &fstest.MapFile{Data: []byte("{}\n")}, + }) + mainSource := filesystem.File("main.yml") + mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits()) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document) + destination := Keys("collection", "empty") + placementNodes := 2 * destinationWrapperNodeCount(destination) + limits := defaultImportLimits() + for _, test := range []struct { + name string + delta int + wantError bool + }{ + {name: "below aggregate bound", delta: -1}, + {name: "at aggregate bound", delta: 0}, + {name: "above aggregate bound", delta: 1, wantError: true}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta} + composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{ + source: mainSource, imports: true, destination: destination, + }}}, 1, nil, accounting) + if test.wantError { + if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded { + t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics) + } + return + } + if len(diagnostics) != 0 || composition == nil || len(sources) != 2 { + t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics) + } + if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta { + t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta) + } + if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 { + t.Fatalf("placed empty import body = %#v, present %v", node, present) + } + }) + } +} + +func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) { + t.Parallel() + + accounting := &importAccounting{} + composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{ + {source: NoSource(), destination: Keys("absent")}, + {source: Mapping("root", map[string]any{})}, + }}, 1, nil, accounting) + if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 { + t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting) + } +} + +func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) { + t.Parallel() + + limits := defaultImportLimits() + baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted")) + + t.Run("selected overlay", func(t *testing.T) { + t.Parallel() + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")}, + }) + request := loadRequest[map[string]any]{ + underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}}, + overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}, + } + accounting := &importAccounting{nodes: limits.nodes - baseNodes} + _, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded { + t.Fatalf("overlay diagnostics = %#v", diagnostics) + } + }) + + t.Run("CLI override", func(t *testing.T) { + t.Parallel() + override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"} + builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)} + if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil { + t.Fatal(issue) + } + overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path) + request := loadRequest[map[string]any]{ + underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}}, + overrides: []Override{override}, + } + accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1} + _, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded { + t.Fatalf("override diagnostics = %#v", diagnostics) + } + }) +} diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 0b9b7ae..e56e309 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -144,9 +144,13 @@ func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath body, declarations, diagnostics := splitImportDirectives(main.document) if len(diagnostics) == 0 { _, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes) + placementNodes := destinationWrapperNodeCount(state.destination) if err != nil { diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")} + } else if accounting.nodes+placementNodes > limits.nodes { + diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)} } else { + accounting.nodes += placementNodes state.documents = append(state.documents, placed) diagnostics = state.walkDeclarations(basePath, declarations, 0) } @@ -303,6 +307,11 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati if err != nil { return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)} } + placementNodes := destinationWrapperNodeCount(s.destination) + if s.accounting.nodes+placementNodes > s.limits.nodes { + return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)} + } + s.accounting.nodes += placementNodes if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil { versionLocation := publicLocation(versionNode.Location) return []Diagnostic{{ diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 93a0d31..449f865 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -56,7 +56,9 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the source depth or node limit after destination placement")} } input.document = placed - if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+countInputNodes(parsed.document) > limits.nodes { + 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 { return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")} } if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil { @@ -64,7 +66,7 @@ func assembleBaseInputTracked[T any](source Source, options Options[T], destinat } accounting.sources++ accounting.bytes += parsed.bytes - accounting.nodes += countInputNodes(parsed.document) + accounting.nodes += rawNodes + placementNodes } if !activeOverlay(options.Overlay) || options.Imports { contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)