Expose sequence placement controls in the public API

This commit is contained in:
2026-10-16 19:23:43 +00:00
parent c19825620c
commit adef16ccdd
13 changed files with 573 additions and 55 deletions

View File

@@ -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 {

View File

@@ -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

View File

@@ -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

View File

@@ -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.

323
facade_sequence_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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

View File

@@ -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] {

View File

@@ -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})

View File

@@ -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

View File

@@ -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{}