From adef16ccdd518e9ec14c25805560a6cd1cb75730 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Fri, 16 Oct 2026 19:23:43 +0000 Subject: [PATCH] Expose sequence placement controls in the public API --- conversion.go | 5 +- docs/config.md | 78 ++++-- docs/policy/architecture.md | 7 + docs/roadmap/implementation.md | 21 +- facade_sequence_test.go | 323 +++++++++++++++++++++++ internal/engine/destination.go | 20 ++ internal/engine/imports.go | 33 ++- internal/engine/records.go | 18 ++ internal/engine/sequence_registration.go | 16 ++ layered.go | 11 +- lifecycle.go | 54 +++- records.go | 23 +- records_test.go | 19 +- 13 files changed, 573 insertions(+), 55 deletions(-) create mode 100644 facade_sequence_test.go diff --git a/conversion.go b/conversion.go index 2077d12..a6ab423 100644 --- a/conversion.go +++ b/conversion.go @@ -43,10 +43,11 @@ func toEngineLocations(values []Location) []engine.Location { return result } func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { - return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)} + return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)} } func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { - return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)} + result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)} + return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved) } func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { if value == nil { diff --git a/docs/config.md b/docs/config.md index 47eb2dd..d398a3a 100644 --- a/docs/config.md +++ b/docs/config.md @@ -67,13 +67,18 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar contributes no source, and continues through defaults, decode, and projection. A missing file remains an error; it is never interpreted as no input. -The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver, -semantic validator, explicit path and glob imports, one selected overlay, and -filesystem context. It also accepts explicit CLI override records, static secret -tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays, -and filesystem context as invalid combinations but accepts overrides. Input -containing a reserved `configkit` mapping key is rejected while imports are -disabled. +`Options` accepts the application definition, ordinary-source `Destination` and +`PlacementMode`, explicit path and glob imports, one selected overlay, filesystem +context, explicit CLI overrides, and shared source-version controls. The definition +may register strict sequence composition paths through +`SequenceCompositions`. Its zero-valued placement mode places the source mapping +at the destination; `sequence-item` explicitly wraps the mapping as one item at a +non-root destination. Unknown modes, invalid destinations, and invalid or +conflicting sequence registrations fail before source acquisition. Overlay and +CLI coordinates remain rooted at the application root. `NoSource` rejects +imports, active overlays, and filesystem context as invalid combinations but +accepts overrides. Input containing a reserved `configkit` mapping key is rejected +while imports are disabled. Every source, options, schema, version, defaults, decode, resolution, or validation failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached @@ -176,12 +181,21 @@ follow the same strict composition within a tier and recursive precedence behavior described above. A selected overlay and CLI overrides remain rooted at the application root and are not moved beneath the main input's destination. -`SourceInfo.Destination` reports the placement for each acquired input source, -including imports. Placement does not change source IDs, acquisition identity, -physical source locations, or passive resource contexts. Introduced ancestor -mappings use the contributing document's root location as a source fallback; -authored content retains its original source coordinates. Returned source -records are detached copies. +`SourceInfo.Destination` reports each acquired source's effective mapping or +sequence-container path, including imports. `PlacementMode` reports how that +source was placed; mapping is the zero value, and CLI/overlay sources report +mapping unless item placement was applied. `DestinationUnresolved` is true only +when an enclosing item index needed to name the source's destination has not yet +been assigned. In that case `Destination` reports the nearest resolved enclosing +collection container and omits the unresolved local suffix. A source's own item +placement still has an exact destination at its sequence container. Completed +loads project nested source destinations through their owning items. Callback and +failed-load snapshots preserve the evidence available at that point; a fallback +snapshot remains unresolved, while a later exact snapshot is false. Returned +source records and paths are detached. Placement does not change source IDs, +acquisition identity, physical source locations, or passive resource contexts. +Introduced ancestor mappings use the contributing document's root location as a +source fallback; authored content retains its original source coordinates. The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input) shows a main host file, two underlay tiers, embedded defaults, a mapping source, @@ -744,12 +758,39 @@ Application defaults do not participate in this operation. | Mapping | Mapping | Recurse by mapping key | | Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` | -Every non-mapping overlap is a conflict, even when values are equal. Nulls and -sequences are present atomic values, so they also conflict when repeated; sequences -are never joined by index. Disjoint entries under shared mappings compose, making +Every non-mapping overlap is a conflict, even when values are equal, except for +sequence/sequence contributions at a registered sequence-composition path. Nulls +remain atomic conflicts. Unregistered sequences conflict when repeated and are +never joined by index. Disjoint entries under shared mappings compose, making stable keyed mappings the natural representation for independently contributed entries. +`Definition.SequenceCompositions` registers literal mapping paths and one literal +`IdentityField` for sequences that should compose independently authored records. +Registration paths follow input-destination key rules: parsed numeric tokens are +literal mapping keys, explicit index segments and reserved `configkit` or +mapping-valued `extends` segments are invalid, and the root registration is +harmless and unused. The identity field is one literal string key; neither the +field name nor identity strings are normalized. +At each registered path, every actual sequence contribution in a strict tier is +validated, including a singleton contribution: each record must be a mapping with +a present string identity field. Distinct exact identity strings append in source +and list order; duplicate identities conflict even if the records are otherwise +equal or disjoint. Empty identity strings are valid. Malformed records produce +`compose.invalid`; duplicate identities produce `compose.conflict`, with the +sequence container and source locations identifying a duplicate without exposing +identity values. Missing paths and non-sequence values do not fail by themselves. +Identical registrations coalesce; conflicting registrations at one path and +invalid registration paths produce `options.invalid`. Unused and root registrations +are allowed and have no effect. + +Registration changes strict composition only. Each underlay group and the main +tier has independent duplicate-identity state. Precedence assembly is unchanged: +a present higher-priority sequence replaces the lower sequence as one value, +including at a registered path. Overlays, CLI overrides, defaults, inheritance, +and application resolution do not run the registered identity check. The accepted +rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md). + An empty mapping contributes no child definitions. Two empty mappings compose, and an empty mapping neither clears nor conflicts with another mapping's children. Shared mapping containers retain all contributing value locations as their composite @@ -889,8 +930,9 @@ Each path or glob declaration may include `placement: mapping` or including every match of a glob; placement is not inherited. Sequence-item placement requires a nonempty effective destination and wraps the imported mapping as one element of a sequence there. It does not split source sequences -or enable sequence accumulation: overlapping sequences still follow the strict -composition rules above. +or infer identity fields. Repeated sequences still conflict unless their path is +registered through `Definition.SequenceCompositions`; registration does not +change placement or precedence behavior. An item import first opens a local mapping scope. Its body and ordinary helper imports compose within that record. An omitted helper destination or diff --git a/docs/policy/architecture.md b/docs/policy/architecture.md index 07fec48..9122342 100644 --- a/docs/policy/architecture.md +++ b/docs/policy/architecture.md @@ -170,6 +170,13 @@ Tier precedence is explicit; traversal order within a tier establishes stable reporting, not permission to override. The distinction and its rationale are recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md). +At definition-registered sequence paths, strict composition appends mapping +records with distinct present string identities and rejects duplicates. +Placement and registration are explicit source and definition controls; they do +not change precedence replacement. The [configuration contract](../config.md#strict-document-composition) +defines the behavior, and [ADR 0014](../adr/0014-sequence-placement-and-composition.md) +records the decision. + The main document and its imports form a strictly composed tier. Explicit underlay groups compose strictly within each group, then combine recursively from lowest to highest beneath the main tier. Source acquisition kind does not diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 1baf6a8..aff0227 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–10 completed. +Status: implementation in progress; stages 1–11 completed. ## Purpose and authority @@ -658,6 +658,25 @@ Validation: focused facade tests, Completion: both public entry points expose the complete feature with the same composition rules and independently owned evidence. +Completion evidence: the root facade now exposes sequence registrations, +ordinary and layered placement controls, and detached placement-mode and +destination-resolution source evidence. Public integration coverage exercises +ordinary/layered placement, strict registration with authored and glob-imported +items, shifted origins and resource contexts, option preflight, early and +post-placement failure snapshots, nested fallback containers, registration and +snapshot ownership, and zero-value mapping behavior. The existing resolver +callback runs after item projection, so its source snapshots are exact; the +unresolved helper fallback is observable on early failure. No acquisition-time +callback exposes `SourceInfo`, and no additional callback lifecycle was added. +These checks passed with Go `go1.26.9 linux/amd64`: + +- `GOTOOLCHAIN=local GOPROXY=off go test . -run 'TestPublicFacadeBoundary|TestDependencyImportBoundary' -count=1` +- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` +- `GOTOOLCHAIN=local GOPROXY=off ./scripts/check.sh` +- `git diff --check` + +No environmental limitation affected validation; checks ran on Linux/amd64. + ## Stage 12 — Close cross-cutting regression and limit gaps Scope: risks that require the assembled feature, not duplicate mechanism tests. diff --git a/facade_sequence_test.go b/facade_sequence_test.go new file mode 100644 index 0000000..71f6ac6 --- /dev/null +++ b/facade_sequence_test.go @@ -0,0 +1,323 @@ +package configkit_test + +import ( + "os" + "path/filepath" + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) { + definition := configkit.Definition[map[string]any]{ + SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}}, + } + ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{ + Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem, + }) + if err != nil { + t.Fatalf("ordinary item load: %v", err) + } + if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" { + t.Fatalf("ordinary item placement = %#v", ordinary.Config) + } + if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved { + t.Fatalf("ordinary item source = %#v", sources) + } + + layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{ + {Source: configkit.Mapping("first", map[string]any{"id": "first"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem}, + {Source: configkit.Mapping("second", map[string]any{"id": "second"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem}, + }}}, + }) + if err != nil { + t.Fatalf("layered item load: %v", err) + } + items := layered.Config["entries"].([]any) + if len(items) != 2 || items[0].(map[string]any)["id"] != "first" || items[1].(map[string]any)["id"] != "second" { + t.Fatalf("layered item placement = %#v", layered.Config) + } + for _, source := range layered.Sources() { + if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved { + t.Errorf("layered item source = %#v", source) + } + } + + precedence, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{ + "entries": []any{map[string]any{"id": "main"}}, + })}, configkit.LayeredOptions[map[string]any]{ + Definition: definition, + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{ + Source: configkit.Mapping("lower", map[string]any{"id": "lower"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem, + }}}}, + }) + if err != nil { + t.Fatalf("registered sequence precedence load: %v", err) + } + if got := precedence.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "main" { + t.Fatalf("higher-priority list did not replace the lower sequence: %#v", precedence.Config) + } + + for _, test := range []struct { + name string + load func(configkit.Source) error + }{ + {name: "ordinary", load: func(source configkit.Source) error { + _, loadErr := configkit.Load(source, configkit.Options[map[string]any]{PlacementMode: configkit.PlacementMode(9)}) + return loadErr + }}, + {name: "layered", load: func(source configkit.Source) error { + _, loadErr := configkit.LoadLayered[map[string]any](configkit.Input{Source: source, PlacementMode: configkit.PlacementMode(9)}) + return loadErr + }}, + } { + t.Run(test.name, func(t *testing.T) { + reader := &destinationCountingReader{} + err := test.load(configkit.Reader("unread", reader)) + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || reader.reads != 0 { + t.Fatalf("invalid placement result = %v, reads = %d", err, reader.reads) + } + }) + } +} + +func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) { + directory := t.TempDir() + mainPath := filepath.Join(directory, "main.yml") + if err := os.WriteFile(mainPath, []byte(`configkit: + imports: + - glob: records/*.yml + destination: [items] + placement: sequence-item +items: + - id: authored + name: authored item +`), 0o600); err != nil { + t.Fatal(err) + } + for name, id := range map[string]string{"b.yml": "imported-b", "a.yml": "imported-a"} { + if err := os.MkdirAll(filepath.Join(directory, "records"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "records", name), []byte("id: "+id+"\n"), 0o600); err != nil { + t.Fatal(err) + } + } + var callbackSources []configkit.SourceInfo + var callbackContexts []configkit.ResourceContext + result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{ + Imports: true, + Definition: configkit.Definition[map[string]any]{ + SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("items"), IdentityField: "id"}}, + Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { + for _, id := range []string{"source-2", "source-3"} { + source, ok := resolution.Source(id) + if !ok { + t.Errorf("resolver could not query %s", id) + continue + } + context, found := resolution.SourceContext(id) + if !found || context.FS == nil || context.BaseDir != "records" { + t.Errorf("resolver context for %s = %#v, %v", id, context, found) + } + callbackSources = append(callbackSources, source) + callbackContexts = append(callbackContexts, context) + } + return nil + }, + }, + }) + if err != nil { + t.Fatalf("load item glob: %v", err) + } + items := result.Config["items"].([]any) + wantIDs := []string{"authored", "imported-a", "imported-b"} + for index, item := range items { + if got := item.(map[string]any)["id"]; got != wantIDs[index] { + t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index]) + } + } + if len(callbackSources) != 2 || len(callbackContexts) != 2 { + t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts) + } + for index, source := range callbackSources { + if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved { + t.Errorf("callback source = %#v", source) + } + if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" { + t.Errorf("callback context = %#v", callbackContexts[index]) + } + } + metadata := result.Metadata() + for index := 1; index < len(wantIDs); index++ { + path := configkit.Keys("items").Index(index).Key("id") + info, ok := metadata.Lookup(path) + if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] { + t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok) + } + } + if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved { + t.Fatalf("final item sources = %#v", got) + } +} + +func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) { + directory := t.TempDir() + mainPath := filepath.Join(directory, "main.yml") + helperPath := filepath.Join(directory, "helper.yml") + if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{ + Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, + }) + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("early failure = %v", err) + } + sources := loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved || + sources[0].PlacementMode != configkit.PlacementModeSequenceItem || + sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved || + sources[1].PlacementMode != configkit.PlacementModeMapping { + t.Fatalf("early source snapshots = %#v", sources) + } + + if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil { + t.Fatal(err) + } + type item struct { + ID string `yaml:"id"` + Metadata struct { + Value int `yaml:"value"` + } `yaml:"metadata"` + } + type configuration struct { + Items []item `yaml:"items"` + } + _, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{ + Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, + }) + loadError, ok = err.(*configkit.LoadError) + if !ok { + t.Fatalf("post-placement failure = %v", err) + } + sources = loadError.Sources() + if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved || + sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved { + t.Fatalf("post-placement source snapshots = %#v", sources) + } + firstSnapshot := sources[1] + sources[1].Destination = configkit.Keys("mutated") + if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved { + t.Fatalf("failure source snapshots were aliased: %#v", again) + } + + var callback configkit.SourceInfo + _, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{ + Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, + Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error { + callback, _ = resolution.Source("source-2") + return nil + }}, + }) + if err != nil { + t.Fatalf("successful item load: %v", err) + } + if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping { + t.Fatalf("callback source snapshot = %#v", callback) + } +} + +func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) { + directory := t.TempDir() + mainPath := filepath.Join(directory, "main.yml") + childPath := filepath.Join(directory, "child.yml") + helperPath := filepath.Join(directory, "helper.yml") + if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{ + Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem, + }) + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("nested acquisition failure = %v", err) + } + sources := loadError.Sources() + if len(sources) != 3 { + t.Fatalf("nested source snapshots = %#v", sources) + } + if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem { + t.Errorf("outer item snapshot = %#v", got) + } + if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem { + t.Errorf("nested item fallback = %#v", got) + } + if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping { + t.Errorf("nested helper fallback = %#v", got) + } +} + +func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) { + registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}} + definition := configkit.Definition[map[string]any]{ + SequenceCompositions: registrations, + Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: func(configkit.Version, *configkit.Validation) error { + registrations[0].Path = configkit.Keys("other") + registrations[0].IdentityField = "changed" + return nil + }, + }, + } + _, err := configkit.Load(configkit.Mapping("registration", map[string]any{ + "version": "1", + "records": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}, + }), configkit.Options[map[string]any]{ + Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent, + }) + loadError, ok := err.(*configkit.LoadError) + if !ok { + t.Fatalf("duplicate registered identity = %v", err) + } + for _, diagnostic := range loadError.Diagnostics() { + if diagnostic.Code == configkit.CodeComposeConflict { + return + } + } + t.Fatalf("registration mutation changed the in-flight policy: %#v", loadError.Diagnostics()) +} + +func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) { + zero := configkit.SourceInfo{} + if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved { + t.Fatalf("zero source info = %#v", zero) + } + loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true})) + if err != nil { + t.Fatal(err) + } + sources := loaded.Sources() + if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved { + t.Fatalf("mapping source evidence = %#v", sources) + } + first := sources[0] + first.Destination = configkit.Keys("caller") + if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved { + t.Fatalf("source evidence changed after caller mutation: %#v", again) + } +} diff --git a/internal/engine/destination.go b/internal/engine/destination.go index ab0c49c..7bdceb2 100644 --- a/internal/engine/destination.go +++ b/internal/engine/destination.go @@ -18,6 +18,26 @@ const ( placementModeSequenceItem ) +// PlacementModeFromFacade preserves root-owned numeric values until preflight +// can reject unsupported modes. +func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) } + +// PlacementModeForFacade converts private source evidence to the root-owned mode. +func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) } + +// InputWithPlacement copies the root-owned mode into a private engine input. +func InputWithPlacement(input Input, value uint8) Input { + input.placementMode = placementMode(value) + return input +} + +// OptionsWithPlacement copies root-owned controls into private engine options. +func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] { + options.destination = destination.clone() + options.placementMode = placementMode(value) + return options +} + func normalizeDestination(path Path) (Path, error) { if !path.valid() { return Path{}, errInvalidDestination diff --git a/internal/engine/imports.go b/internal/engine/imports.go index b3c38b0..8202a48 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -51,13 +51,14 @@ type importTraversal struct { rule *VersionRule versionState *sourceVersionState - scope acquisitionScope - itemScope bool - itemPrefix Path - sources []SourceInfo - active map[resourceIdentity]int - visited map[resourceIdentity]importRoute - stack []importRoute + scope acquisitionScope + itemScope bool + itemPrefix Path + itemFallback Path + 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) { @@ -135,9 +136,6 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, mainInfo := cloneSourceInfo(*main.source) mainInfo.Destination = destination.clone() mainIsItem := main.source.placementMode == placementModeSequenceItem - if mainIsItem { - mainInfo.destinationUnresolved = true - } if mainPath != "" { mainInfo.Path = mainPath } @@ -161,6 +159,7 @@ func loadImportedWithAccessTrackedAndProcessingPolicy(access *filesystemAccess, if mainIsItem { state.itemScope = true state.itemPrefix = destination.clone() + state.itemFallback = destination.clone() state.scope.destination = Path{} state.sources[0] = cloneSourceInfo(mainInfo) } @@ -394,7 +393,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati if declaration.placement == placementModeSequenceItem { info.placementMode = placementModeSequenceItem } - info.destinationUnresolved = s.itemScope || declaration.placement == placementModeSequenceItem + // A source's own item placement still has an exact sequence-container + // destination. Only an unresolved enclosing item index makes the path a + // fallback container for this snapshot. + info.destinationUnresolved = s.itemScope if kind == SourceKindFilesystem { info.namespace = s.access.token } @@ -484,18 +486,21 @@ func (s *importTraversal) sourceDestination(destination Path) Path { if !s.itemScope { return destination.clone() } - return appendDestination(s.itemPrefix, destination) + // Until the enclosing item receives its assigned index, only the collection + // container is exact. Keep the local suffix in the acquisition projection. + 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 { parentScope := s.scope - parentItemScope, parentPrefix := s.itemScope, s.itemPrefix + parentItemScope, parentPrefix, parentFallback := s.itemScope, s.itemPrefix, s.itemFallback s.scope = acquisitionScope{} s.itemScope = true if parentItemScope { s.itemPrefix = appendDestination(parentPrefix, destination) } else { s.itemPrefix = destination.clone() + s.itemFallback = destination.clone() } s.scope.contributions = append(s.scope.contributions, acquiredContribution{ source: cloneSourceInfo(info), payload: body, document: body, @@ -503,7 +508,7 @@ func (s *importTraversal) visitItemSource(canonical string, declaration importDe }) diagnostics := s.walkDeclarations(s.access.dir(canonical), declarations, depth, Path{}) itemScope := s.scope - s.scope, s.itemScope, s.itemPrefix = parentScope, parentItemScope, parentPrefix + s.scope, s.itemScope, s.itemPrefix, s.itemFallback = parentScope, parentItemScope, parentPrefix, parentFallback if len(diagnostics) != 0 { return diagnostics } diff --git a/internal/engine/records.go b/internal/engine/records.go index 9c0b45e..889045e 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -197,6 +197,24 @@ type SourceInfo struct { destinationUnresolved bool } +// SourceInfoPlacementModeForFacade converts private placement evidence to its +// root-owned numeric representation. +func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 { + return uint8(source.placementMode) +} + +// SourceInfoDestinationUnresolvedForFacade returns the private fallback marker. +func SourceInfoDestinationUnresolvedForFacade(source SourceInfo) bool { + return source.destinationUnresolved +} + +// SourceInfoWithPlacement copies facade-owned source evidence into private fields. +func SourceInfoWithPlacement(source SourceInfo, mode uint8, unresolved bool) SourceInfo { + source.placementMode = placementMode(mode) + source.destinationUnresolved = unresolved + return source +} + // SourceVersionCheck is a detached, public-safe record of a source-local // version declaration observed during an enabled load. type SourceVersionCheck struct { diff --git a/internal/engine/sequence_registration.go b/internal/engine/sequence_registration.go index ad9a057..142b145 100644 --- a/internal/engine/sequence_registration.go +++ b/internal/engine/sequence_registration.go @@ -9,6 +9,22 @@ type sequencePolicy struct { identityFields map[string]string } +// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a +// detached engine definition without exposing the registration representation. +func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] { + if paths == nil { + definition.sequenceRegistrations = nil + return definition + } + definition.sequenceRegistrations = make([]sequenceRegistration, len(paths)) + for index := range paths { + definition.sequenceRegistrations[index] = sequenceRegistration{ + path: paths[index].clone(), identityField: identityFields[index], + } + } + return definition +} + func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) { policy := sequencePolicy{identityFields: make(map[string]string, len(registrations))} var diagnostics []Diagnostic diff --git a/layered.go b/layered.go index 423a67d..cfe878d 100644 --- a/layered.go +++ b/layered.go @@ -12,8 +12,12 @@ type Input struct { // path or append a relative destination declared in YAML. Its zero value // keeps root placement. Destination Path - Imports bool - Filesystem *FilesystemContext + // PlacementMode defaults to mapping placement. Sequence-item placement wraps + // the source mapping as one record; unknown values and an empty destination + // fail option validation before acquisition. + PlacementMode PlacementMode + Imports bool + Filesystem *FilesystemContext } // Underlay contains inputs that must compose strictly within one tier. Tiers @@ -55,10 +59,11 @@ func LoadLayered[T any](main Input, supplied ...LayeredOptions[T]) (*Result[T], } func toEngineInput(input Input) engine.Input { - return engine.Input{ + result := engine.Input{ Source: input.Source.toEngine(), Imports: input.Imports, Filesystem: toEngineFilesystemContext(input.Filesystem), Destination: toEnginePath(input.Destination), } + return engine.InputWithPlacement(result, uint8(input.PlacementMode)) } func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOptions[T] { diff --git a/lifecycle.go b/lifecycle.go index 262ab6e..042fec8 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -15,6 +15,37 @@ type Definition[T any] struct { Validate func(*T, *Validation) Version *VersionRule Secret func(Path) bool + // SequenceCompositions registers mapping paths whose sequence contributions + // strictly append distinct records by the selected literal identity field. + // Repeated identities and conflicting registrations fail option validation. + // Registration paths are copied when a load begins; definitions are read-only + // after being shared. + SequenceCompositions []SequenceComposition +} + +// PlacementMode selects how an input mapping is placed at its Destination. +// The zero value, PlacementModeMapping, preserves ordinary mapping placement. +// Unknown values and sequence-item placement at the root fail option validation. +type PlacementMode uint8 + +const ( + // PlacementModeMapping places a source mapping at Destination. + PlacementModeMapping PlacementMode = iota + // PlacementModeSequenceItem wraps a source mapping as one item at Destination. + PlacementModeSequenceItem +) + +// SequenceComposition registers a sequence path for strict identity-based +// composition. Path is copied at load start; IdentityField is one literal key, +// including the empty string. Invalid or conflicting registrations fail option +// validation. +type SequenceComposition struct { + // Path selects a literal mapping path; registration paths are copied at load + // start. Invalid paths fail option validation; the root path is harmless. + Path Path + // IdentityField is one literal mapping key and may be empty. Records must + // contain a string at this key; malformed records fail strict composition. + IdentityField string } // SourceVersionMode controls schema-version declarations across one complete @@ -44,10 +75,17 @@ const ( // Imports enables explicit path and glob imports; Overlay selects one fixed overlay; // Overrides contains only values explicitly supplied by an application CLI. // Enabled source-version modes interpret Definition.Version.Path in each -// document's local coordinates. Main protection remains at that path prefixed -// by the main input's Destination. +// document's local coordinates. Placement projects checks and main protection +// through the input destination; item placement adds the source's item +// coordinate to that projection. See docs/config.md for the full contract. type Options[T any] struct { - Definition Definition[T] + Definition Definition[T] + // Destination places the ordinary source beneath literal mapping keys. Its + // zero value means the application root. Invalid paths fail before acquisition. + Destination Path + // PlacementMode defaults to mapping placement; unknown values and root item + // placement fail before acquisition. + PlacementMode PlacementMode Imports bool Overlay string Filesystem *FilesystemContext @@ -261,6 +299,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] { Overlay: options.Overlay, SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), } + result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode)) if options.Filesystem != nil { result.Filesystem = toEngineFilesystemContext(options.Filesystem) } @@ -289,6 +328,15 @@ func toEngineFilesystemContext(context *FilesystemContext) *engine.FilesystemCon func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] { result := engine.Definition[T]{Defaults: definition.Defaults} + if definition.SequenceCompositions != nil { + paths := make([]engine.Path, len(definition.SequenceCompositions)) + identityFields := make([]string, len(definition.SequenceCompositions)) + for index, registration := range definition.SequenceCompositions { + paths[index] = toEnginePath(registration.Path.clone()) + identityFields[index] = registration.IdentityField + } + result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields) + } if definition.Resolve != nil { result.Resolve = func(configuration *T, resolution *engine.Resolution) error { return definition.Resolve(configuration, &Resolution{value: resolution}) diff --git a/records.go b/records.go index cde66ae..a51ce2a 100644 --- a/records.go +++ b/records.go @@ -189,14 +189,21 @@ type SourceInfo struct { ID string Label string Path string - // Destination is the effective mapping path. Imports inherit their declaring - // document's path unless they append a YAML destination; overlay and CLI - // sources use the root path. - Destination Path - Layer Layer - Kind SourceKind - UnderlayIndex int - ImportedFrom *Location + // Destination is the effective mapping or sequence-container path. It is a + // detached snapshot. A fallback container is marked by DestinationUnresolved. + Destination Path + // PlacementMode describes how this source was placed. Mapping is the zero + // value; CLI and overlay sources report mapping placement unless item placement + // was applied to that source. + PlacementMode PlacementMode + // DestinationUnresolved marks Destination as the nearest resolved enclosing + // container when an enclosing item index is not yet assigned. It is false for + // exact destinations, including an item source's own sequence container. + DestinationUnresolved bool + Layer Layer + Kind SourceKind + UnderlayIndex int + ImportedFrom *Location // VersionCheck is sanitized evidence for source-local declaration processing // in an enabled source-version load. It is nil when that processing did not // occur, including default mode and synthetic CLI sources. During callbacks diff --git a/records_test.go b/records_test.go index 8f0ee42..a8e48b0 100644 --- a/records_test.go +++ b/records_test.go @@ -1,6 +1,10 @@ package configkit -import "testing" +import ( + "testing" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" +) func TestOverlayPublicValues(t *testing.T) { if LayerOverlay != "overlay" { @@ -23,14 +27,17 @@ func TestOverlayPublicValues(t *testing.T) { func TestSourceInfoDestinationConversionsAndCopies(t *testing.T) { destination := Keys("collections", "a/b", "0") - public := SourceInfo{ID: "source-1", Destination: destination} + public := SourceInfo{ + ID: "source-1", Destination: destination, + PlacementMode: PlacementModeSequenceItem, DestinationUnresolved: true, + } internal := toEngineSourceInfo(public) - if internal.Destination.String() != destination.String() { - t.Fatalf("engine destination = %q, want %q", internal.Destination.String(), destination.String()) + if internal.Destination.String() != destination.String() || engine.SourceInfoPlacementModeForFacade(internal) != uint8(PlacementModeSequenceItem) || !engine.SourceInfoDestinationUnresolvedForFacade(internal) { + t.Fatalf("engine source evidence = %#v", internal) } converted := fromEngineSourceInfo(internal) - if converted.Destination.String() != destination.String() { - t.Fatalf("public destination = %q, want %q", converted.Destination.String(), destination.String()) + if converted.Destination.String() != destination.String() || converted.PlacementMode != PlacementModeSequenceItem || !converted.DestinationUnresolved { + t.Fatalf("public source evidence = %#v", converted) } copy := cloneSourceInfo(converted) copy.Destination = Path{}