Expose sequence identity registrations through the facade

This commit is contained in:
2026-10-23 20:59:23 +00:00
parent 33a51606da
commit 0023617a07
20 changed files with 530 additions and 212 deletions

View File

@@ -71,8 +71,8 @@ missing file remains an error; it is never interpreted as no input.
`Options` accepts the application definition, ordinary-source `Destination` and
`PlacementMode`, explicit 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
may register sequence identities through `Definition.SequenceIdentities`. 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
@@ -811,33 +811,39 @@ validated, but there are no imported peers to combine.
| 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, except for
sequence/sequence contributions at a registered sequence-composition path or a
source-declared collection destination. Nulls remain atomic conflicts.
sequence/sequence contributions at a registered sequence identity destination
or a source-declared collection destination. Nulls remain atomic conflicts.
Unregistered and undeclared 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.
`Definition.SequenceIdentities` contains registrations created by
`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a
mapping-only `Path`; the matching form accepts literal-key and any-mapping-key
segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal
`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens
remain literal mapping keys; explicit index segments and reserved `configkit` or
mapping-valued `extends` segments are invalid. The root selector is harmless under
the mapping-root schema. Each registration requires one or more literal field
names; compound field sets are normalized as sets, and field names are not
otherwise normalized.
At each selected path, records must provide every registered field as a scalar
string, number, boolean, or explicit null. The type-tagged tuple preserves
category distinctions, while equivalent finite decimal spellings such as `1`,
`1.0`, and `1e0` compare as the same number. Distinct tuples append in source and
list order; duplicate tuples conflict even when records are otherwise equal or
disjoint. Malformed records produce `compose.invalid`; duplicate identities
produce `compose.conflict`, with paths and source locations identifying a
duplicate without exposing identity values. Missing paths and non-sequence values
do not fail by themselves. Equivalent overlapping registrations coalesce;
incompatible overlaps and invalid registrations produce `options.invalid` before
source acquisition. Unused and root registrations are allowed and have no effect.
Registration controls strict composition and supplies identity fields for parent
lookup when surviving records in that sequence use `extends`. Each underlay group
lookup when surviving records in that sequence use `extends`. Dynamic selectors
apply at strict-composition and inheritance destinations without requiring the
application to enumerate mapping keys. 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. Registration does not add a final
@@ -1092,7 +1098,7 @@ 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 infer identity fields. Repeated sequences still conflict unless their path is
registered through `Definition.SequenceCompositions`; registration does not
registered through `Definition.SequenceIdentities`; registration does not
change placement or precedence behavior. Sequence records retain source
locations through placement and history; indexes describe their assembled
positions while source coordinates continue to identify the declaring files.

View File

@@ -1,6 +1,6 @@
# Sequence Identity And Combination Implementation Plan
Status: implementation in progress; Stages 1–21 complete.
Status: implementation in progress; Stages 1–22 complete.
Audience: a Luna coding agent implementing one numbered stage per prompt, in
order. This plan starts at commit
@@ -760,6 +760,11 @@ Scope: the settled facade surface and migration of in-repository callers.
Validation: root sequence/facade, registration, public/dependency-boundary, and
consumer example tests plus the everyday check.
**Completion:** Exposed the root-owned exact and dynamic sequence identity
registration API, migrated maintained callers, and removed the old single-field
registration adapters. Verified facade, registration, inheritance,
public/dependency-boundary, and consumer example tests plus `./scripts/check.sh`.
## Stage 23 — Separate Reusable CLI Override Preparation
Scope: mechanical preparation refactor and a privately tested post-structural

View File

@@ -45,10 +45,9 @@ func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalog
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
return configkit.Definition[PromptCatalogConfig]{
SequenceCompositions: []configkit.SequenceComposition{{
Path: configkit.Keys("prompts"),
IdentityField: "id",
}},
SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
},
Validate: validatePromptCatalog,
}
}

View File

@@ -40,8 +40,8 @@ entries:
Imports: true,
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "id"}},
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
Secret: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},
@@ -105,8 +105,8 @@ entries:
Imports: true,
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
Definition: Definition[map[string]any]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "version"}},
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
Secret: func(candidate Path) bool {
return candidate.String() == "/services/records/0/version"
},

View File

@@ -133,12 +133,12 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin
"config/identified.yml": &fstest.MapFile{Data: input},
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
})
registrations := []configkit.SequenceComposition{
{Path: configkit.Keys("jobs"), IdentityField: "id"},
registrations := []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
}
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
SequenceCompositions: registrations,
SequenceIdentities: registrations,
Secret: func(path configkit.Path) bool {
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
},
@@ -220,8 +220,8 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.
`)
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
Definition: configkit.Definition[configuration]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("jobs"), IdentityField: "id"}},
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
},
})
if err == nil || strings.Contains(err.Error(), canary) {

View File

@@ -0,0 +1,240 @@
package configkit_test
import (
"errors"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
fields := []string{"namespace", "id", "id"}
selector := configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}
identity := configkit.SequenceIdentityMatching(selector, fields...)
selector[1] = configkit.SequenceLiteralKey("other")
fields[0] = "changed"
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
}})
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
}})
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
})
if err != nil {
t.Fatalf("load after mutating constructor inputs: %v", err)
}
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Fatalf("registered compound identity did not append records: %#v", prompts)
}
}
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id")
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
dynamic,
}}
underlay := configkit.Mapping("underlay", map[string]any{
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
},
})
main := configkit.Mapping("main", map[string]any{
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
"collections": map[string]any{
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
},
})
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
})
if err != nil {
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
}
rootRecords := loaded.Config["prompts"].([]any)
if len(rootRecords) != 2 {
t.Fatalf("compound root identities = %#v", rootRecords)
}
collections := loaded.Config["collections"].(map[string]any)
for _, name := range []string{"team", "cloud"} {
prompts := collections[name].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
}
}
}
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceLiteralKey("*"),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
}})
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
}})
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
}
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
Definition: wildcard,
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
})
if err != nil {
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
}
for name, raw := range loaded.Config["collections"].(map[string]any) {
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
t.Errorf("wildcard result for %q has %d records", name, got)
}
}
}
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
tests := []struct {
name string
identity configkit.SequenceIdentity
wantPath string
}{
{
name: "index path",
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
wantPath: "/records/0",
},
{
name: "empty fields",
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
wantPath: "/records",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
reader := strings.NewReader("records: []\n")
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
}
if reader.Len() == 0 {
t.Fatal("source was read before registration validation")
}
})
}
}
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(selector, "id"),
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
}, "slug"),
}}
reader := strings.NewReader("collections: {}\n")
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
loadErr := requireFacadeLoadError(t, err)
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
}
if reader.Len() == 0 {
t.Fatal("source was read before overlapping registrations were rejected")
}
}
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("entries"),
configkit.SequenceLiteralKey("child"),
configkit.SequenceLiteralKey("collections"),
configkit.SequenceAnyMappingKey(),
configkit.SequenceLiteralKey("prompts"),
}, "id"),
}}
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
entries:
parent:
collections:
team:
prompts: [{id: inherited, text: base}]
child:
extends: parent
collections:
team:
prompts: [{id: added, text: child}]
`)), configkit.Options[map[string]any]{Definition: definition})
if err != nil {
t.Fatalf("load with dynamic inherited destination: %v", err)
}
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
if len(prompts) != 2 {
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
}
}
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
}, "id"),
}}
for _, source := range []string{"first", "second"} {
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
source: []any{map[string]any{"id": "reused"}},
}}), configkit.Options[map[string]any]{Definition: definition})
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
}
}
}
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
t.Helper()
var loadErr *configkit.LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %v, want *LoadError", err)
}
return loadErr
}

View File

@@ -21,7 +21,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
}
if registered {
definition.SequenceCompositions = []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
}
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
if err != nil {
@@ -63,7 +63,7 @@ func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
"child-helper.yml": {Data: []byte("text: nested helper\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
})
if err != nil {
t.Fatal(err)
@@ -114,7 +114,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
mainYAML += "records: [{id: main}]\n"
}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
})
if err != nil {
@@ -143,7 +143,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
"helper.yml": {Data: []byte("id: duplicate-field\n")},
})
definition := configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}
for _, test := range []struct {
name, file, path string
grouped, itemMain bool
@@ -200,7 +200,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "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,
@@ -301,7 +301,7 @@ items:
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"}},
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, id := range []string{"source-2", "source-3"} {
source, ok := resolution.Source(id)
@@ -461,15 +461,17 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
}
}
func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
fields := []string{"id"}
path := configkit.Keys("records")
registration := configkit.SequenceIdentityAt(path, fields...)
fields[0] = "changed"
definition := configkit.Definition[map[string]any]{
SequenceCompositions: registrations,
SequenceIdentities: []configkit.SequenceIdentity{registration},
Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(configkit.Version, *configkit.Validation) error {
registrations[0].Path = configkit.Keys("other")
registrations[0].IdentityField = "changed"
path = configkit.Keys("other")
return nil
},
},
@@ -514,7 +516,7 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
Definition: definition,
@@ -595,7 +597,7 @@ entries:
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
Definition: configkit.Definition[configuration]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
Secret: func(path configkit.Path) bool {
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
},

View File

@@ -23,9 +23,9 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
}
first := "records: [{id: first, version: first-version}]\n"
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
@@ -86,8 +86,8 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
for _, grouped := range []bool{false, true} {
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
definition := configkit.Definition[map[string]any]{
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
}
first := "records: [{id: first, version: first}]\n"
child := "records: [{id: child, version: child}]\n"

View File

@@ -10,17 +10,20 @@ import (
)
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
policy := sequencePolicy{identityFields: make(map[string]string)}
registrations := make([]sequenceIdentityRegistration, 0, 128)
var inputs []*document.Node
for source := 0; source < 3; source++ {
var text strings.Builder
for collection := 0; collection < 128; collection++ {
key := fmt.Sprintf("records%d", collection)
policy.identityFields[Keys(key).String()] = "id"
registrations = append(registrations, sequenceIdentityRegistration{
selector: sequenceIdentitySelector{literalSequenceSelector(key)}, identityFields: []string{"id"},
})
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
}
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
}
policy := mustCompileSequenceIdentityPolicy(t, registrations)
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
@@ -242,7 +245,7 @@ func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
@@ -287,10 +290,10 @@ escaped/~:
name: escaped-second-name
`)
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
policy := sequencePolicy{identityFields: map[string]string{
Keys("mounted", "records").String(): "id",
Keys("escaped/~", "7").String(): "id",
}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("mounted"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("escaped/~"), literalSequenceSelector("7")}, identityFields: []string{"id"}},
})
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
if len(diagnostics) != 0 {
@@ -442,7 +445,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", test.body),
)
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
@@ -570,7 +573,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T)
}
_, diagnostics = composeDocumentsWithCollectionPolicy(
sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower,
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower,
[]sequenceDeclarationSpec{declaration},
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
)
@@ -581,7 +584,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T)
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
)
@@ -613,7 +616,7 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
}
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), inputs...,
)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
@@ -631,9 +634,12 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
}
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("absent")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("not-a-sequence")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
})
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
not-a-sequence: null
catalog:
@@ -647,7 +653,7 @@ catalog:
}
composed, diagnostics = composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "records: []\n"),
parseCompositionDocument(t, "source-2", "records: []\n"),
)
@@ -664,7 +670,7 @@ catalog:
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("registered")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
)
@@ -684,7 +690,7 @@ func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testin
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, diagnostics := composeDocumentsWithPolicy(
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}),
parseCompositionDocument(t, "source-1", test.first),
parseCompositionDocument(t, "source-2", test.second),
)

View File

@@ -197,8 +197,8 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
t.Parallel()
definition := Definition[map[string]any]{
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}},
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
}
item := func(label string, value map[string]any) Input {
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}

View File

@@ -10,7 +10,10 @@ import (
)
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/other": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("other")}, identityFields: []string{"id"}},
})
first := parseCompositionDocument(t, "source-1", "records: [{id: a, value: first}]\nother: [{id: x}]\n")
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
@@ -76,7 +79,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
}
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id", "/independent": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("independent")}, identityFields: []string{"id"}},
})
first, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
@@ -148,7 +154,7 @@ func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
}
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"),
@@ -193,7 +199,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T
}
func TestReplacedProjectedSensitiveRecordRemainsRedacted(t *testing.T) {
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
composed, diagnostics := composeDocumentsWithPolicy(policy,
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
parseCompositionDocument(t, "second", "records: [{id: second, token: PRIVATE-RECORD-CANARY}]"),

View File

@@ -115,7 +115,7 @@ tasks: [{id: z-parent}]
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
Imports: true,
sequencePolicy: sequencePolicy{identityFields: map[string]string{"/tasks": "id"}},
sequencePolicy: mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("tasks")}, identityFields: []string{"id"}}}),
})
if len(diagnostics) != 0 {
t.Fatalf("assemble imports: %#v", diagnostics)
@@ -679,7 +679,7 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
t.Fatalf("assemble item import: %#v", diagnostics)
}
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
composed, diagnostics := composeDocumentsWithPolicy(sequencePolicy{identityFields: map[string]string{"/items": "id"}}, authored, input.document)
composed, diagnostics := composeDocumentsWithPolicy(mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}), authored, input.document)
if len(diagnostics) != 0 {
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
}
@@ -1009,7 +1009,7 @@ func TestItemContributionsUseDependencyReadySchedules(t *testing.T) {
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:

View File

@@ -197,7 +197,7 @@ func TestNestedItemPolicyProjectionUsesStableOutputIndexes(t *testing.T) {
func TestOverlaySequenceTransfersIncomingItemPolicyOwnership(t *testing.T) {
path := Keys("records")
registered := mustCompileSequencePolicy(t, []sequenceRegistration{{path: path, identityField: "id"}})
registered := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}})
makePolicies := func(childKey string) collectionPolicyScope {
local := collectionPolicyScope{}
local.set(Keys("steps"), collectionPolicy{

View File

@@ -17,7 +17,7 @@ type Definition[T any] struct {
Version *VersionRule
Secret func(Path) bool
sequenceRegistrations []sequenceRegistration
sequenceIdentityRegistrations []sequenceIdentityRegistration
}
// Options contains the application definition and per-load input selections.
@@ -146,13 +146,13 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
options, diagnostics := selectOptions(source, supplied)
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
diagnostics = append(diagnostics, policyDiagnostics...)
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
definition := options.Definition
definition.sequenceRegistrations = nil
definition.sequenceIdentityRegistrations = nil
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
definition: definition, importDefaults: snapshotImportDefaults(options.importDefaults), sequencePolicy: policy, overlay: options.Overlay,

View File

@@ -52,10 +52,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
var diagnostics []Diagnostic
request.importDefaults = snapshotImportDefaults(request.importDefaults)
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
request.sequencePolicy = policy
diagnostics = append(diagnostics, policyDiagnostics...)
request.definition.sequenceRegistrations = nil
request.definition.sequenceIdentityRegistrations = nil
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,

View File

@@ -46,7 +46,7 @@ items: [{id: b}]
{source: File(zRoot), imports: true},
{source: File(bRoot), imports: true},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
result, err := testGroupedLoad(request)
if err != nil {
@@ -132,7 +132,7 @@ func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testi
{source: secondNamespace.File("root.yml")},
{source: firstNamespace.File("later.yml")},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
result, err := testGroupedLoad(request)
if err != nil {
@@ -163,7 +163,7 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
@@ -207,7 +207,7 @@ func TestUnderlayItemHelperConflictUsesScheduledGroupIndex(t *testing.T) {
{source: File(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
{source: File(aGood), destination: Keys("items"), placementMode: placementModeSequenceItem},
}}},
definition: Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}},
definition: Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}},
}
_, err := testGroupedLoad(request)
loadError, ok := err.(*LoadError)
@@ -403,11 +403,11 @@ func TestLayeredCollectionPolicyFlowsBetweenTiers(t *testing.T) {
filesystem := NewFilesystem("overlay", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("configkit:\n sequences:\n - destination: [items]\n key: id\n mode: append\nitems: [{id: overlay}]\n")},
})
definition := Definition[map[string]any]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[map[string]any]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
request = loadRequest[map[string]any]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
overlay: "testing", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
}
result, err = testGroupedLoad(request)

View File

@@ -1,10 +1,5 @@
package engine
type sequenceRegistration struct {
path Path
identityField string
}
type sequenceSelectorSegmentKind uint8
const (
@@ -22,6 +17,24 @@ type sequenceIdentitySelector []sequenceSelectorSegment
type sequenceIdentityRegistration struct {
selector sequenceIdentitySelector
identityFields []string
invalidPath *Path
}
// SequenceIdentitySelectorSegment carries one root-owned selector segment into
// an engine definition. This transport type remains behind the internal package
// boundary.
type SequenceIdentitySelectorSegment struct {
Key string
AnyMappingKey bool
}
// SequenceIdentityRegistrationInput carries detached root-owned registration
// data into an engine definition without exposing its compiled policy. A
// non-nil ExactPath selects exact-path registration; otherwise Selector is used.
type SequenceIdentityRegistrationInput struct {
ExactPath *Path
Selector []SequenceIdentitySelectorSegment
IdentityFields []string
}
type sequenceSelectorTrie struct {
@@ -32,62 +45,59 @@ type sequenceSelectorTrie struct {
}
type sequencePolicy struct {
// identityFields preserves the old exact-path view for existing internal
// callers while selector matching is introduced behind the same policy.
identityFields map[string]string
selectorTrie *sequenceSelectorTrie
selectorTrie *sequenceSelectorTrie
}
// 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
// DefinitionWithSequenceIdentityRegistrations attaches facade-owned
// registrations to a detached engine definition without exposing compiled
// engine policy.
func DefinitionWithSequenceIdentityRegistrations[T any](definition Definition[T], registrations []SequenceIdentityRegistrationInput) Definition[T] {
if registrations == nil {
definition.sequenceIdentityRegistrations = nil
return definition
}
definition.sequenceRegistrations = make([]sequenceRegistration, len(paths))
for index := range paths {
definition.sequenceRegistrations[index] = sequenceRegistration{
path: paths[index].clone(), identityField: identityFields[index],
definition.sequenceIdentityRegistrations = make([]sequenceIdentityRegistration, len(registrations))
for index, registration := range registrations {
compiled := sequenceIdentityRegistration{identityFields: append([]string(nil), registration.IdentityFields...)}
if registration.ExactPath != nil {
path, err := normalizeDestination(registration.ExactPath.clone())
if err != nil {
invalid := registration.ExactPath.clone()
compiled.invalidPath = &invalid
} else {
compiled.selector = make(sequenceIdentitySelector, len(path.segments))
for segmentIndex, segment := range path.segments {
compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key}
}
}
} else {
compiled.selector = make(sequenceIdentitySelector, len(registration.Selector))
for segmentIndex, segment := range registration.Selector {
kind := sequenceSelectorLiteral
if segment.AnyMappingKey {
kind = sequenceSelectorAnyMappingKey
}
compiled.selector[segmentIndex] = sequenceSelectorSegment{kind: kind, key: segment.Key}
}
}
definition.sequenceIdentityRegistrations[index] = compiled
}
return definition
}
func compileSequencePolicy(registrations []sequenceRegistration) (sequencePolicy, []Diagnostic) {
common := make([]sequenceIdentityRegistration, 0, len(registrations))
var diagnostics []Diagnostic
legacyPaths := make(map[string]string, len(registrations))
for _, registration := range registrations {
path, err := normalizeDestination(registration.path)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: registration.path.clone(), Message: "sequence registration path must contain only non-reserved mapping keys",
})
continue
}
selector := make(sequenceIdentitySelector, len(path.segments))
for index, segment := range path.segments {
selector[index] = sequenceSelectorSegment{kind: sequenceSelectorLiteral, key: segment.key}
}
common = append(common, sequenceIdentityRegistration{
selector: selector, identityFields: []string{registration.identityField},
})
legacyPaths[path.String()] = registration.identityField
}
policy, compiledDiagnostics := compileSequenceIdentityPolicy(common)
policy.identityFields = legacyPaths
diagnostics = append(diagnostics, compiledDiagnostics...)
return policy, canonicalDiagnostics(diagnostics)
}
func compileSequenceIdentityPolicy(registrations []sequenceIdentityRegistration) (sequencePolicy, []Diagnostic) {
policy := sequencePolicy{}
compiled := make([]sequenceIdentityRegistration, 0, len(registrations))
var diagnostics []Diagnostic
for _, registration := range registrations {
selector := cloneSequenceIdentitySelector(registration.selector)
if registration.invalidPath != nil {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError, Path: registration.invalidPath.clone(),
Message: "sequence registration path must contain only non-reserved mapping keys",
})
continue
}
fields, err := normalizeSequenceIdentityFields(registration.identityFields)
if err != nil {
diagnostics = append(diagnostics, Diagnostic{
@@ -172,11 +182,7 @@ func (p sequencePolicy) identityFieldsAt(path Path) ([]string, bool) {
return nil, false
}
if p.selectorTrie == nil {
field, ok := p.identityFields[path.String()]
if !ok {
return nil, false
}
return []string{field}, true
return nil, false
}
current := []*sequenceSelectorTrie{p.selectorTrie}
for _, segment := range path.segments {

View File

@@ -7,54 +7,44 @@ import (
"testing/fstest"
)
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
parsed, err := ParsePath("/items/0/a~1b")
if err != nil {
t.Fatal(err)
}
func TestCompileSequenceIdentityPolicyNormalizesRegistrations(t *testing.T) {
tests := []struct {
name string
registrations []sequenceRegistration
registrations []sequenceIdentityRegistration
want map[string]string
wantError bool
}{
{
name: "identical registrations coalesce",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "id"},
registrations: []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
},
want: map[string]string{"/items": "id"},
},
{
name: "different identity fields conflict",
registrations: []sequenceRegistration{
{path: Keys("items"), identityField: "id"},
{path: Keys("items"), identityField: "name"},
registrations: []sequenceIdentityRegistration{
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"name"}},
},
wantError: true,
},
{
name: "empty identity field is literal",
registrations: []sequenceRegistration{{path: Keys("items"), identityField: ""}},
registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{""}}},
want: map[string]string{"/items": ""},
},
{
name: "numeric and escaped tokens remain literal keys",
registrations: []sequenceRegistration{{path: parsed, identityField: "id"}},
registrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items"), literalSequenceSelector("0"), literalSequenceSelector("a/b")}, identityFields: []string{"id"}}},
want: map[string]string{"/items/0/a~1b": "id"},
},
{
name: "explicit index is invalid",
registrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}},
wantError: true,
},
{
name: "unused and root registrations are accepted",
registrations: []sequenceRegistration{
{path: Path{}, identityField: "id"},
{path: Keys("unused"), identityField: "name"},
registrations: []sequenceIdentityRegistration{
{selector: nil, identityFields: []string{"id"}},
{selector: sequenceIdentitySelector{literalSequenceSelector("unused")}, identityFields: []string{"name"}},
},
want: map[string]string{"": "id", "/unused": "name"},
},
@@ -63,7 +53,7 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
policy, diagnostics := compileSequencePolicy(test.registrations)
policy, diagnostics := compileSequenceIdentityPolicy(test.registrations)
if (len(diagnostics) != 0) != test.wantError {
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
}
@@ -73,12 +63,14 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
}
return
}
if len(policy.identityFields) != len(test.want) {
t.Fatalf("compiled fields = %#v, want %#v", policy.identityFields, test.want)
}
for path, field := range test.want {
if got := policy.identityFields[path]; got != field {
t.Errorf("identity field at %q = %q, want %q", path, got, field)
parsed, err := ParsePath(path)
if err != nil {
t.Fatal(err)
}
gotFields, ok := policy.identityFieldsAt(parsed)
if !ok || len(gotFields) != 1 || gotFields[0] != field {
t.Errorf("identity fields at %q = %#v, want [%q]", path, gotFields, field)
}
}
})
@@ -247,7 +239,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
ordinaryReader := strings.NewReader("value: ordinary\n")
_, err := Load[config](Reader("ordinary.yml", ordinaryReader), SecretBinding{}, Options[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}),
})
requireSequenceRegistrationOptionError(t, err)
if ordinaryReader.Len() == 0 {
@@ -257,7 +249,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
mainReader := strings.NewReader("value: main\n")
underlayReader := strings.NewReader("value: underlay\n")
_, err = LoadLayered[config](Input{Source: Reader("main.yml", mainReader)}, SecretBinding{}, LayeredOptions[config]{
Definition: Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items").Index(0), identityField: "id"}}},
Definition: DefinitionWithSequenceIdentityRegistrations(Definition[config]{}, []SequenceIdentityRegistrationInput{{ExactPath: pathPointer(Keys("items").Index(0)), IdentityFields: []string{"id"}}}),
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}},
})
requireSequenceRegistrationOptionError(t, err)
@@ -266,6 +258,8 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
}
}
func pathPointer(path Path) *Path { return &path }
func requireSequenceRegistrationOptionError(t *testing.T, err error) {
t.Helper()
if err == nil {
@@ -289,7 +283,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
type config struct {
Items []item `yaml:"items"`
}
definition := Definition[config]{sequenceRegistrations: []sequenceRegistration{{path: Keys("items"), identityField: "id"}}}
definition := Definition[config]{sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}}}}
registered := Options[config]{Definition: definition}
loadCode := func(t *testing.T, err error) Code {
t.Helper()
@@ -327,7 +321,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
{source: Bytes("second", []byte("items: [{id: other}]\n"))},
}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
result, err := testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 3 || result.Config.Items[0].ID != "lower" || result.Config.Items[1].ID != "other" || result.Config.Items[2].ID != "shared" {
@@ -352,7 +346,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
result, err = testGroupedLoad(request)
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "lower" {
@@ -378,7 +372,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
request := loadRequest[config]{
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\n"))}}}},
definition: definition, sequencePolicy: mustCompileSequencePolicy(t, definition.sequenceRegistrations),
definition: definition, sequencePolicy: mustCompileSequenceIdentityPolicy(t, definition.sequenceIdentityRegistrations),
}
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
t.Fatalf("hidden lower-tier error = %s", got)
@@ -447,9 +441,9 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
})
}
func mustCompileSequencePolicy(t *testing.T, registrations []sequenceRegistration) sequencePolicy {
func mustCompileSequenceIdentityPolicy(t *testing.T, registrations []sequenceIdentityRegistration) sequencePolicy {
t.Helper()
policy, diagnostics := compileSequencePolicy(registrations)
policy, diagnostics := compileSequenceIdentityPolicy(registrations)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}

View File

@@ -15,15 +15,11 @@ 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.
// Invalid or conflicting registrations fail option validation. Repeated input
// identities fail strict composition after source and helper processing. The
// field also supplies identity for parent lookup when surviving records at
// that path use record-inheritance directives.
// Registration paths are copied when a load begins; definitions are read-only
// after being shared.
SequenceCompositions []SequenceComposition
// SequenceIdentities declares the scalar fields used to combine records and
// resolve record inheritance at selected sequence destinations. Registration
// errors are reported before source acquisition. These constraints do not yet
// validate identities introduced by defaults, CLI overrides, or callbacks.
SequenceIdentities []SequenceIdentity
}
// PlacementMode selects how an input mapping is placed at its Destination.
@@ -38,20 +34,6 @@ const (
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.
// The field also identifies parents for record inheritance at this path.
IdentityField string
}
// SourceVersionMode controls schema-version declarations across one complete
// load. The zero value preserves the existing main-only effective-path check
// and its protected-path behavior. Enabled modes check participating documents
@@ -360,14 +342,12 @@ 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
if definition.SequenceIdentities != nil {
registrations := make([]engine.SequenceIdentityRegistrationInput, len(definition.SequenceIdentities))
for index, identity := range definition.SequenceIdentities {
registrations[index] = toEngineSequenceIdentity(identity)
}
result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields)
result = engine.DefinitionWithSequenceIdentityRegistrations(result, registrations)
}
if definition.Resolve != nil {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {

74
sequence_identity.go Normal file
View File

@@ -0,0 +1,74 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// SequenceSelector identifies a sequence destination through literal mapping
// keys and arbitrary mapping-key segments. A zero selector identifies the root.
type SequenceSelector []SequenceSelectorSegment
// SequenceSelectorSegment is one literal mapping key or wildcard mapping key.
// Its zero value is the literal empty key.
type SequenceSelectorSegment struct {
key string
anyMappingKey bool
}
// SequenceLiteralKey selects a literal mapping key, including an empty key or
// the literal string "*".
func SequenceLiteralKey(key string) SequenceSelectorSegment {
return SequenceSelectorSegment{key: key}
}
// SequenceAnyMappingKey selects any single mapping key.
func SequenceAnyMappingKey() SequenceSelectorSegment {
return SequenceSelectorSegment{anyMappingKey: true}
}
// SequenceIdentity records the identity fields used for strict sequence
// composition and identified-record parent lookup. It does not add final-result
// identity validation; that check remains the application's responsibility.
type SequenceIdentity struct {
exactPath bool
path Path
selector SequenceSelector
identityFields []string
}
// SequenceIdentityAt registers identity fields at an exact mapping path. The
// path and fields are copied; invalid paths and empty field sets are reported
// during load validation.
func SequenceIdentityAt(path Path, fields ...string) SequenceIdentity {
return SequenceIdentity{
exactPath: true,
path: path.clone(),
identityFields: append([]string(nil), fields...),
}
}
// SequenceIdentityMatching registers identity fields at destinations matched by
// selector. Literal and wildcard selector segments are copied. Empty field sets
// are reported during load validation.
func SequenceIdentityMatching(selector SequenceSelector, fields ...string) SequenceIdentity {
return SequenceIdentity{
selector: append(SequenceSelector(nil), selector...),
identityFields: append([]string(nil), fields...),
}
}
func toEngineSequenceIdentity(identity SequenceIdentity) engine.SequenceIdentityRegistrationInput {
registration := engine.SequenceIdentityRegistrationInput{
IdentityFields: append([]string(nil), identity.identityFields...),
}
if identity.exactPath {
path := toEnginePath(identity.path.clone())
registration.ExactPath = &path
return registration
}
registration.Selector = make([]engine.SequenceIdentitySelectorSegment, len(identity.selector))
for index, segment := range identity.selector {
registration.Selector[index] = engine.SequenceIdentitySelectorSegment{
Key: segment.key, AnyMappingKey: segment.anyMappingKey,
}
}
return registration
}