Expose sequence identity registrations through the facade
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
240
facade_sequence_identity_test.go
Normal file
240
facade_sequence_identity_test.go
Normal 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
|
||||
}
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}]"),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
40
lifecycle.go
40
lifecycle.go
@@ -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
74
sequence_identity.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user