diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 96c4f24..b617336 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # Sequence Identity And Combination Implementation Plan -Status: implementation in progress; Stages 1–22 complete. +Status: implementation in progress; Stages 1–23 complete. Audience: a Luna coding agent implementing one numbered stage per prompt, in order. This plan starts at commit @@ -786,6 +786,11 @@ validation entry, without changing current CLI processing order. Validation: override preparation/source tests and the everyday check. Keep ordinary/layered behavior unchanged; typed assignment is the next stage. +**Completion:** Extracted detached override preparation batches with stable +synthetic source ordering and added a privately exercised post-structural +directive check for `extends`. Existing early override behavior remains active. +Verified focused override tests and `./scripts/check.sh`. + ## Stage 24 — Implement Schema-Aware Post-Decode CLI Assignment Scope: a private typed assignment helper using Stage 23's prepared overrides, diff --git a/internal/engine/override.go b/internal/engine/override.go index f41981c..d9bd8f5 100644 --- a/internal/engine/override.go +++ b/internal/engine/override.go @@ -19,9 +19,40 @@ type preparedOverride struct { nodes int } -func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) { +// preparedOverrideBatch owns all override values and synthetic source records +// needed to apply one CLI batch after its preparation checks succeed. +type preparedOverrideBatch struct { + overrides []preparedOverride + sources []SourceInfo +} + +type overridePreparationInput struct { + root *document.Node + inputBytes int64 + inputNodes int +} + +func overridePreparationInputFor(base *composedDocument) overridePreparationInput { + if base == nil { + return overridePreparationInput{} + } + return overridePreparationInput{root: base.root, inputBytes: base.inputBytes, inputNodes: base.inputNodes} +} + +func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (preparedOverrideBatch, []Diagnostic) { + return prepareOverrideBatch(overridePreparationInputFor(base), sources, overrides, rule, limits, false) +} + +// preparePostStructuralOverrides prepares the same detached batch for the +// lifecycle after structural expansion, where record inheritance directives +// are no longer valid CLI input. +func preparePostStructuralOverrides(base overridePreparationInput, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (preparedOverrideBatch, []Diagnostic) { + return prepareOverrideBatch(base, sources, overrides, rule, limits, true) +} + +func prepareOverrideBatch(base overridePreparationInput, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits, rejectInheritanceDirectives bool) (preparedOverrideBatch, []Diagnostic) { if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources { - return nil, cloneSourceInfos(sources), []Diagnostic{{ + return preparedOverrideBatch{sources: cloneSourceInfos(sources)}, []Diagnostic{{ Code: CodeOverrideLimitExceeded, Severity: SeverityError, Message: "configuration overrides exceed the record or source-count limit", }} @@ -37,7 +68,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides [] } } if len(diagnostics) != 0 { - return nil, cloneSourceInfos(sources), canonicalDiagnostics(diagnostics) + return preparedOverrideBatch{sources: cloneSourceInfos(sources)}, canonicalDiagnostics(diagnostics) } prepared := make([]preparedOverride, len(overrides)) @@ -89,7 +120,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides [] }) continue } - if pathContainsDirective(override.Path) { + if pathContainsDirective(override.Path) || (rejectInheritanceDirectives && pathContainsInheritanceDirective(override.Path)) { diagnostics = append(diagnostics, Diagnostic{ Code: CodeOverrideDirective, Severity: SeverityError, Path: override.Path.clone(), Message: "configuration overrides cannot create configuration directives", Primary: location, @@ -106,7 +137,11 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides [] diagnostics = append(diagnostics, diagnostic) continue } - if relative, ok := overrideDirectivePath(value, Path{}); ok { + findDirective := overrideDirectivePath + if rejectInheritanceDirectives { + findDirective = overrideStructuralDirectivePath + } + if relative, ok := findDirective(value, Path{}); ok { diagnostics = append(diagnostics, Diagnostic{ Code: CodeOverrideDirective, Severity: SeverityError, Path: appendOverridePath(override.Path, relative), Message: "configuration overrides cannot create configuration directives", Primary: location, @@ -136,10 +171,10 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides [] usedNodes += nodes } if len(diagnostics) != 0 { - return nil, allSources, canonicalDiagnostics(diagnostics) + return preparedOverrideBatch{sources: allSources}, canonicalDiagnostics(diagnostics) } - return prepared, allSources, nil + return preparedOverrideBatch{overrides: prepared, sources: allSources}, nil } func validOverrideLabel(label string) bool { @@ -191,6 +226,15 @@ func pathContainsDirective(path Path) bool { return false } +func pathContainsInheritanceDirective(path Path) bool { + for _, segment := range path.segments { + if segment.key == inheritanceKey { + return true + } + } + return false +} + func appendOverridePath(base, relative Path) Path { result := base.clone() result.segments = append(result.segments, clonePathSegments(relative.segments, 0)...) @@ -198,19 +242,27 @@ func appendOverridePath(base, relative Path) Path { } func overrideDirectivePath(node *document.Node, path Path) (Path, bool) { + return overrideDirectivePathWithInheritance(node, path, false) +} + +func overrideStructuralDirectivePath(node *document.Node, path Path) (Path, bool) { + return overrideDirectivePathWithInheritance(node, path, true) +} + +func overrideDirectivePathWithInheritance(node *document.Node, path Path, rejectInheritanceDirectives bool) (Path, bool) { if node.Kind == document.Mapping { for _, entry := range node.Mapping { child := path.Key(entry.Key) - if entry.Key == directiveKey { + if entry.Key == directiveKey || (rejectInheritanceDirectives && entry.Key == inheritanceKey) { return child, true } - if found, ok := overrideDirectivePath(entry.Value, child); ok { + if found, ok := overrideDirectivePathWithInheritance(entry.Value, child, rejectInheritanceDirectives); ok { return found, true } } } else if node.Kind == document.Sequence { for index, child := range node.Sequence { - if found, ok := overrideDirectivePath(child, path.Index(index)); ok { + if found, ok := overrideDirectivePathWithInheritance(child, path.Index(index), rejectInheritanceDirectives); ok { return found, true } } diff --git a/internal/engine/override_apply.go b/internal/engine/override_apply.go index 3ff7f72..beba4c4 100644 --- a/internal/engine/override_apply.go +++ b/internal/engine/override_apply.go @@ -20,16 +20,16 @@ func (a *assemblyAccumulator) applyOverrides(sources []SourceInfo, overrides []O if len(overrides) == 0 { return cloneSourceInfos(sources), nil } - prepared, allSources, diagnostics := prepareOverrides(a.current, sources, overrides, rule, limits) + batch, diagnostics := prepareOverrides(a.current, sources, overrides, rule, limits) if len(diagnostics) != 0 { - return allSources, diagnostics + return batch.sources, diagnostics } - for _, override := range prepared { + for _, override := range batch.overrides { applyPreparedOverride(a.current, override, &a.index) a.current.inputBytes += override.bytes a.current.inputNodes += override.nodes } - return allSources, nil + return batch.sources, nil } func applyPreparedOverride(result *composedDocument, override preparedOverride, index *mappingIndex) { diff --git a/internal/engine/override_test.go b/internal/engine/override_test.go index d3c7153..5494484 100644 --- a/internal/engine/override_test.go +++ b/internal/engine/override_test.go @@ -446,6 +446,85 @@ func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) { } } +func TestPreparedOverrideBatchDetachesInputsAndAllocatesSourcesInOrder(t *testing.T) { + base, diagnostics := composeDocuments(parseCompositionDocument(t, "base", "settings: {}\n")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + sources := []SourceInfo{{ID: "source-1", Label: "base", Layer: LayerBase, Kind: SourceKindMapping}} + firstValue := map[string]any{"nested": map[string]any{"name": "original"}} + secondValue := []any{map[string]any{"enabled": true}} + batch, diagnostics := prepareOverrides(base, sources, []Override{ + {Path: Keys("settings", "first"), Value: firstValue, Flag: "--first"}, + {Path: Keys("settings", "second"), Value: secondValue, Flag: "--second"}, + }, nil, defaultImportLimits()) + if len(diagnostics) != 0 { + t.Fatalf("prepare overrides: %#v", diagnostics) + } + if len(batch.overrides) != 2 || len(batch.sources) != 3 { + t.Fatalf("prepared counts = %d overrides, %d sources", len(batch.overrides), len(batch.sources)) + } + for index, want := range []string{"source-2", "source-3"} { + if batch.overrides[index].source.ID != want || batch.sources[index+1].ID != want { + t.Errorf("prepared source %d = %#v / %#v, want %s", index, batch.overrides[index].source, batch.sources[index+1], want) + } + } + if batch.sources[1].Label != "--first" || batch.sources[2].Label != "--second" { + t.Fatalf("synthetic source order = %#v", batch.sources) + } + + firstValue["nested"].(map[string]any)["name"] = "changed after preparation" + preparedName, found := documentAtPath(batch.overrides[0].value, Keys("nested", "name")) + if !found || preparedName.Text != "original" { + t.Fatalf("prepared override aliases input value: %#v, %v", preparedName, found) + } + preparedName.Text = "changed prepared snapshot" + if firstValue["nested"].(map[string]any)["name"] != "changed after preparation" { + t.Fatal("prepared override mutation changed caller input") + } + batch.sources[0].Label = "changed batch source" + if sources[0].Label != "base" { + t.Fatal("prepared source list aliases the input source list") + } + if len(base.root.Mapping) != 1 || base.root.Mapping[0].Value.Kind != document.Mapping || len(base.root.Mapping[0].Value.Mapping) != 0 { + t.Fatalf("preparation mutated assembled input: %#v", base.root) + } +} + +func TestPostStructuralOverridePreparationRejectsInheritanceDirectives(t *testing.T) { + base, diagnostics := composeDocuments(parseCompositionDocument(t, "base", "child: {}\nitems: []\n")) + if len(diagnostics) != 0 { + t.Fatal(diagnostics) + } + tests := []struct { + name string + override Override + path string + }{ + { + name: "supplied path", + override: Override{Path: Keys("child", "extends"), Value: "parent", Flag: "--extends"}, + path: "/child/extends", + }, + { + name: "nested value", + override: Override{Path: Keys("items"), Value: []any{map[string]any{"extends": "parent"}}, Flag: "--items"}, + path: "/items/0/extends", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + if _, diagnostics := prepareOverrides(base, nil, []Override{test.override}, nil, defaultImportLimits()); len(diagnostics) != 0 { + t.Fatalf("legacy preparation changed before lifecycle activation: %#v", diagnostics) + } + _, diagnostics := preparePostStructuralOverrides(overridePreparationInputFor(base), nil, []Override{test.override}, nil, defaultImportLimits()) + if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideDirective || diagnostics[0].Path.String() != test.path { + t.Fatalf("post-structural diagnostics = %#v, want directive at %s", diagnostics, test.path) + } + }) + } +} + func historyLayers(history []Change) []Layer { layers := make([]Layer, len(history)) for index, change := range history {