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
|
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||||
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
||||||
context, explicit CLI overrides, and shared source-version controls. The definition
|
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||||
may register strict sequence composition paths through
|
may register sequence identities through `Definition.SequenceIdentities`. Its
|
||||||
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
zero-valued placement mode places the source mapping
|
||||||
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||||
non-root destination. Unknown modes, invalid destinations, and invalid or
|
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
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` |
|
| 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
|
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
|
sequence/sequence contributions at a registered sequence identity destination
|
||||||
source-declared collection destination. Nulls remain atomic conflicts.
|
or a source-declared collection destination. Nulls remain atomic conflicts.
|
||||||
Unregistered and undeclared sequences conflict when repeated and are never joined
|
Unregistered and undeclared sequences conflict when repeated and are never joined
|
||||||
by index. Disjoint entries under shared mappings compose, making stable keyed
|
by index. Disjoint entries under shared mappings compose, making stable keyed
|
||||||
mappings the natural representation for independently contributed entries.
|
mappings the natural representation for independently contributed entries.
|
||||||
|
|
||||||
`Definition.SequenceCompositions` registers literal mapping paths and one literal
|
`Definition.SequenceIdentities` contains registrations created by
|
||||||
`IdentityField` for sequences that should compose independently authored records.
|
`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a
|
||||||
Registration paths follow input-destination key rules: parsed numeric tokens are
|
mapping-only `Path`; the matching form accepts literal-key and any-mapping-key
|
||||||
literal mapping keys, explicit index segments and reserved `configkit` or
|
segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal
|
||||||
mapping-valued `extends` segments are invalid, and the root registration is
|
`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens
|
||||||
harmless and unused. The identity field is one literal string key; neither the
|
remain literal mapping keys; explicit index segments and reserved `configkit` or
|
||||||
field name nor identity strings are normalized.
|
mapping-valued `extends` segments are invalid. The root selector is harmless under
|
||||||
At each registered path, every actual sequence contribution in a strict tier is
|
the mapping-root schema. Each registration requires one or more literal field
|
||||||
validated, including a singleton contribution: each record must be a mapping with
|
names; compound field sets are normalized as sets, and field names are not
|
||||||
a present string identity field. Distinct exact identity strings append in source
|
otherwise normalized.
|
||||||
and list order; duplicate identities conflict even if the records are otherwise
|
|
||||||
equal or disjoint. Empty identity strings are valid. Malformed records produce
|
At each selected path, records must provide every registered field as a scalar
|
||||||
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
|
string, number, boolean, or explicit null. The type-tagged tuple preserves
|
||||||
sequence container and source locations identifying a duplicate without exposing
|
category distinctions, while equivalent finite decimal spellings such as `1`,
|
||||||
identity values. Missing paths and non-sequence values do not fail by themselves.
|
`1.0`, and `1e0` compare as the same number. Distinct tuples append in source and
|
||||||
Identical registrations coalesce; conflicting registrations at one path and
|
list order; duplicate tuples conflict even when records are otherwise equal or
|
||||||
invalid registration paths produce `options.invalid`. Unused and root registrations
|
disjoint. Malformed records produce `compose.invalid`; duplicate identities
|
||||||
are allowed and have no effect.
|
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
|
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
|
and the main tier has independent duplicate-identity state. Precedence assembly
|
||||||
is unchanged: a present higher-priority sequence replaces the lower sequence as
|
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
|
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
|
placement requires a nonempty effective destination and wraps the imported
|
||||||
mapping as one element of a sequence there. It does not split source sequences
|
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
|
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
|
change placement or precedence behavior. Sequence records retain source
|
||||||
locations through placement and history; indexes describe their assembled
|
locations through placement and history; indexes describe their assembled
|
||||||
positions while source coordinates continue to identify the declaring files.
|
positions while source coordinates continue to identify the declaring files.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Sequence Identity And Combination Implementation Plan
|
# 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
|
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
||||||
order. This plan starts at commit
|
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
|
Validation: root sequence/facade, registration, public/dependency-boundary, and
|
||||||
consumer example tests plus the everyday check.
|
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
|
## Stage 23 — Separate Reusable CLI Override Preparation
|
||||||
|
|
||||||
Scope: mechanical preparation refactor and a privately tested post-structural
|
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] {
|
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||||
return configkit.Definition[PromptCatalogConfig]{
|
return configkit.Definition[PromptCatalogConfig]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
Path: configkit.Keys("prompts"),
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
IdentityField: "id",
|
},
|
||||||
}},
|
|
||||||
Validate: validatePromptCatalog,
|
Validate: validatePromptCatalog,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ entries:
|
|||||||
Imports: true,
|
Imports: true,
|
||||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
Definition: Definition[map[string]any]{
|
Definition: Definition[map[string]any]{
|
||||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "id"}},
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||||
Secret: func(candidate Path) bool {
|
Secret: func(candidate Path) bool {
|
||||||
return candidate.String() == "/services/records/0/version"
|
return candidate.String() == "/services/records/0/version"
|
||||||
},
|
},
|
||||||
@@ -105,8 +105,8 @@ entries:
|
|||||||
Imports: true,
|
Imports: true,
|
||||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
Definition: Definition[map[string]any]{
|
Definition: Definition[map[string]any]{
|
||||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "version"}},
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||||
Secret: func(candidate Path) bool {
|
Secret: func(candidate Path) bool {
|
||||||
return candidate.String() == "/services/records/0/version"
|
return candidate.String() == "/services/records/0/version"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -133,12 +133,12 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin
|
|||||||
"config/identified.yml": &fstest.MapFile{Data: input},
|
"config/identified.yml": &fstest.MapFile{Data: input},
|
||||||
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
||||||
})
|
})
|
||||||
registrations := []configkit.SequenceComposition{
|
registrations := []configkit.SequenceIdentity{
|
||||||
{Path: configkit.Keys("jobs"), IdentityField: "id"},
|
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
|
||||||
}
|
}
|
||||||
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||||
Definition: configkit.Definition[configuration]{
|
Definition: configkit.Definition[configuration]{
|
||||||
SequenceCompositions: registrations,
|
SequenceIdentities: registrations,
|
||||||
Secret: func(path configkit.Path) bool {
|
Secret: func(path configkit.Path) bool {
|
||||||
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
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]{
|
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||||
Definition: configkit.Definition[configuration]{
|
Definition: configkit.Definition[configuration]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("jobs"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err == nil || strings.Contains(err.Error(), canary) {
|
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} }
|
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
||||||
}
|
}
|
||||||
if registered {
|
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})
|
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +63,7 @@ func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
|||||||
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||||
})
|
})
|
||||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -114,7 +114,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
|||||||
mainYAML += "records: [{id: main}]\n"
|
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]{
|
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"}}})}}}},
|
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 {
|
if err != nil {
|
||||||
@@ -143,7 +143,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
||||||
"helper.yml": {Data: []byte("id: duplicate-field\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 {
|
for _, test := range []struct {
|
||||||
name, file, path string
|
name, file, path string
|
||||||
grouped, itemMain bool
|
grouped, itemMain bool
|
||||||
@@ -200,7 +200,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
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]{
|
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,
|
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]{
|
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
Imports: true,
|
Imports: true,
|
||||||
Definition: configkit.Definition[map[string]any]{
|
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 {
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
for _, id := range []string{"source-2", "source-3"} {
|
for _, id := range []string{"source-2", "source-3"} {
|
||||||
source, ok := resolution.Source(id)
|
source, ok := resolution.Source(id)
|
||||||
@@ -461,15 +461,17 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
|
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
|
||||||
registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
fields := []string{"id"}
|
||||||
|
path := configkit.Keys("records")
|
||||||
|
registration := configkit.SequenceIdentityAt(path, fields...)
|
||||||
|
fields[0] = "changed"
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: registrations,
|
SequenceIdentities: []configkit.SequenceIdentity{registration},
|
||||||
Version: &configkit.VersionRule{
|
Version: &configkit.VersionRule{
|
||||||
Path: configkit.Keys("version"),
|
Path: configkit.Keys("version"),
|
||||||
Check: func(configkit.Version, *configkit.Validation) error {
|
Check: func(configkit.Version, *configkit.Validation) error {
|
||||||
registrations[0].Path = configkit.Keys("other")
|
path = configkit.Keys("other")
|
||||||
registrations[0].IdentityField = "changed"
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -514,7 +516,7 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
|||||||
|
|
||||||
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
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]{
|
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,
|
Definition: definition,
|
||||||
@@ -595,7 +597,7 @@ entries:
|
|||||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
|
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}}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
||||||
Definition: configkit.Definition[configuration]{
|
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 {
|
Secret: func(path configkit.Path) bool {
|
||||||
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
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)} {
|
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) {
|
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
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 }},
|
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 },
|
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||||
}
|
}
|
||||||
first := "records: [{id: first, version: first-version}]\n"
|
first := "records: [{id: first, version: first-version}]\n"
|
||||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\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 _, grouped := range []bool{false, true} {
|
||||||
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||||
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
}
|
}
|
||||||
first := "records: [{id: first, version: first}]\n"
|
first := "records: [{id: first, version: first}]\n"
|
||||||
child := "records: [{id: child, version: child}]\n"
|
child := "records: [{id: child, version: child}]\n"
|
||||||
|
|||||||
@@ -10,17 +10,20 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
|
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
|
||||||
policy := sequencePolicy{identityFields: make(map[string]string)}
|
registrations := make([]sequenceIdentityRegistration, 0, 128)
|
||||||
var inputs []*document.Node
|
var inputs []*document.Node
|
||||||
for source := 0; source < 3; source++ {
|
for source := 0; source < 3; source++ {
|
||||||
var text strings.Builder
|
var text strings.Builder
|
||||||
for collection := 0; collection < 128; collection++ {
|
for collection := 0; collection < 128; collection++ {
|
||||||
key := fmt.Sprintf("records%d", 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)
|
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
|
||||||
}
|
}
|
||||||
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
|
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
|
||||||
}
|
}
|
||||||
|
policy := mustCompileSequenceIdentityPolicy(t, registrations)
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
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")
|
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")
|
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
|
||||||
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
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)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -287,10 +290,10 @@ escaped/~:
|
|||||||
name: escaped-second-name
|
name: escaped-second-name
|
||||||
`)
|
`)
|
||||||
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
||||||
policy := sequencePolicy{identityFields: map[string]string{
|
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||||
Keys("mounted", "records").String(): "id",
|
{selector: sequenceIdentitySelector{literalSequenceSelector("mounted"), literalSequenceSelector("records")}, identityFields: []string{"id"}},
|
||||||
Keys("escaped/~", "7").String(): "id",
|
{selector: sequenceIdentitySelector{literalSequenceSelector("escaped/~"), literalSequenceSelector("7")}, identityFields: []string{"id"}},
|
||||||
}}
|
})
|
||||||
|
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
@@ -442,7 +445,7 @@ func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, diagnostics := composeDocumentsWithPolicy(
|
_, 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),
|
parseCompositionDocument(t, "source-1", test.body),
|
||||||
)
|
)
|
||||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
|
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(
|
_, diagnostics = composeDocumentsWithCollectionPolicy(
|
||||||
sequencePolicy{identityFields: map[string]string{path.String(): "id"}}, lower,
|
mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("records")}, identityFields: []string{"id"}}}), lower,
|
||||||
[]sequenceDeclarationSpec{declaration},
|
[]sequenceDeclarationSpec{declaration},
|
||||||
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
|
parseCompositionDocument(t, "source-1", "records: [{name: replacement}]\n"),
|
||||||
)
|
)
|
||||||
@@ -581,7 +584,7 @@ func TestComposeDocumentsReplacementDoesNotBorrowLowerYAMLIdentity(t *testing.T)
|
|||||||
|
|
||||||
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
|
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
|
||||||
_, diagnostics := composeDocumentsWithPolicy(
|
_, 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-1", "records: [{id: first}]\n"),
|
||||||
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\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))
|
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
|
||||||
}
|
}
|
||||||
_, diagnostics := composeDocumentsWithPolicy(
|
_, 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" {
|
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||||
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
|
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
|
||||||
@@ -631,9 +634,12 @@ func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
|
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
|
||||||
policy := sequencePolicy{identityFields: map[string]string{
|
policy := mustCompileSequenceIdentityPolicy(t, []sequenceIdentityRegistration{
|
||||||
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
|
{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", `
|
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
|
||||||
not-a-sequence: null
|
not-a-sequence: null
|
||||||
catalog:
|
catalog:
|
||||||
@@ -647,7 +653,7 @@ catalog:
|
|||||||
}
|
}
|
||||||
|
|
||||||
composed, diagnostics = composeDocumentsWithPolicy(
|
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-1", "records: []\n"),
|
||||||
parseCompositionDocument(t, "source-2", "records: []\n"),
|
parseCompositionDocument(t, "source-2", "records: []\n"),
|
||||||
)
|
)
|
||||||
@@ -664,7 +670,7 @@ catalog:
|
|||||||
|
|
||||||
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
|
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
|
||||||
_, diagnostics := composeDocumentsWithPolicy(
|
_, 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-1", "unregistered: [one]\n"),
|
||||||
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
|
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
|
||||||
)
|
)
|
||||||
@@ -684,7 +690,7 @@ func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testin
|
|||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
_, diagnostics := composeDocumentsWithPolicy(
|
_, 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-1", test.first),
|
||||||
parseCompositionDocument(t, "source-2", test.second),
|
parseCompositionDocument(t, "source-2", test.second),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -197,8 +197,8 @@ func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
definition := Definition[map[string]any]{
|
definition := Definition[map[string]any]{
|
||||||
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
|
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}},
|
sequenceIdentityRegistrations: []sequenceIdentityRegistration{{selector: sequenceIdentitySelector{literalSequenceSelector("catalog"), literalSequenceSelector("entries")}, identityFields: []string{"id"}}},
|
||||||
}
|
}
|
||||||
item := func(label string, value map[string]any) Input {
|
item := func(label string, value map[string]any) Input {
|
||||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
|
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
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")
|
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")
|
second := parseCompositionDocument(t, "source-2", "records: [{id: b, value: second}]\nother: [{id: y}]\n")
|
||||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||||
@@ -76,7 +79,10 @@ func TestSequenceHistoryReplaysAppendAtAssignedIndexes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSequenceHistoryPreservesTierReplacementAndEmptyLists(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,
|
first, diagnostics := composeDocumentsWithPolicy(policy,
|
||||||
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
|
parseCompositionDocument(t, "underlay-1a", "records: [{id: shared}]\nindependent: [{id: x}]\n"),
|
||||||
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
|
parseCompositionDocument(t, "underlay-1b", "records: [{id: lower}]\n"),
|
||||||
@@ -148,7 +154,7 @@ func TestHistoryMergeSupportsSemanticIndexedPrefixes(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(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,
|
composed, diagnostics := composeDocumentsWithPolicy(policy,
|
||||||
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
|
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
|
||||||
parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"),
|
parseCompositionDocument(t, "second", "records: [{id: second, token: private}]"),
|
||||||
@@ -193,7 +199,7 @@ func TestProjectedSequenceHistoryAgreesAcrossMetadataAndExplanation(t *testing.T
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestReplacedProjectedSensitiveRecordRemainsRedacted(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,
|
composed, diagnostics := composeDocumentsWithPolicy(policy,
|
||||||
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
|
parseCompositionDocument(t, "first", "records: [{id: first, token: public}]"),
|
||||||
parseCompositionDocument(t, "second", "records: [{id: second, token: PRIVATE-RECORD-CANARY}]"),
|
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]{
|
input, diagnostics := assembleBaseInput[config](File(mainPath), Options[config]{
|
||||||
Imports: true,
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("assemble imports: %#v", diagnostics)
|
t.Fatalf("assemble imports: %#v", diagnostics)
|
||||||
@@ -679,7 +679,7 @@ func TestItemImportCanJoinAnAuthoredListUnderRegisteredComposition(t *testing.T)
|
|||||||
t.Fatalf("assemble item import: %#v", diagnostics)
|
t.Fatalf("assemble item import: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
authored := parseDocumentForDecode(t, "items: [{id: authored-id, name: authored}]\n")
|
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 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
t.Fatalf("compose authored and imported lists: %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -1009,7 +1009,7 @@ func TestItemContributionsUseDependencyReadySchedules(t *testing.T) {
|
|||||||
type config struct {
|
type config struct {
|
||||||
Items []item `yaml:"items"`
|
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()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
|
|||||||
@@ -197,7 +197,7 @@ func TestNestedItemPolicyProjectionUsesStableOutputIndexes(t *testing.T) {
|
|||||||
|
|
||||||
func TestOverlaySequenceTransfersIncomingItemPolicyOwnership(t *testing.T) {
|
func TestOverlaySequenceTransfersIncomingItemPolicyOwnership(t *testing.T) {
|
||||||
path := Keys("records")
|
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 {
|
makePolicies := func(childKey string) collectionPolicyScope {
|
||||||
local := collectionPolicyScope{}
|
local := collectionPolicyScope{}
|
||||||
local.set(Keys("steps"), collectionPolicy{
|
local.set(Keys("steps"), collectionPolicy{
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ type Definition[T any] struct {
|
|||||||
Version *VersionRule
|
Version *VersionRule
|
||||||
Secret func(Path) bool
|
Secret func(Path) bool
|
||||||
|
|
||||||
sequenceRegistrations []sequenceRegistration
|
sequenceIdentityRegistrations []sequenceIdentityRegistration
|
||||||
}
|
}
|
||||||
|
|
||||||
// Options contains the application definition and per-load input selections.
|
// 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) {
|
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
policy, policyDiagnostics := compileSequencePolicy(options.Definition.sequenceRegistrations)
|
policy, policyDiagnostics := compileSequenceIdentityPolicy(options.Definition.sequenceIdentityRegistrations)
|
||||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
|
||||||
}
|
}
|
||||||
definition := options.Definition
|
definition := options.Definition
|
||||||
definition.sequenceRegistrations = nil
|
definition.sequenceIdentityRegistrations = nil
|
||||||
request := loadRequest[T]{
|
request := loadRequest[T]{
|
||||||
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem, destination: options.destination.clone(), placementMode: options.placementMode},
|
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,
|
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
|
var diagnostics []Diagnostic
|
||||||
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
request.importDefaults = snapshotImportDefaults(request.importDefaults)
|
||||||
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
diagnostics = append(diagnostics, validateImportDefaults(request.importDefaults)...)
|
||||||
policy, policyDiagnostics := compileSequencePolicy(request.definition.sequenceRegistrations)
|
policy, policyDiagnostics := compileSequenceIdentityPolicy(request.definition.sequenceIdentityRegistrations)
|
||||||
request.sequencePolicy = policy
|
request.sequencePolicy = policy
|
||||||
diagnostics = append(diagnostics, policyDiagnostics...)
|
diagnostics = append(diagnostics, policyDiagnostics...)
|
||||||
request.definition.sequenceRegistrations = nil
|
request.definition.sequenceIdentityRegistrations = nil
|
||||||
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
|
||||||
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
|
||||||
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ items: [{id: b}]
|
|||||||
{source: File(zRoot), imports: true},
|
{source: File(zRoot), imports: true},
|
||||||
{source: File(bRoot), 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)
|
result, err := testGroupedLoad(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -132,7 +132,7 @@ func TestUnderlayNamespaceAnchorsUseInputPositionsAndFilesystemIdentity(t *testi
|
|||||||
{source: secondNamespace.File("root.yml")},
|
{source: secondNamespace.File("root.yml")},
|
||||||
{source: firstNamespace.File("later.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)
|
result, err := testGroupedLoad(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -163,7 +163,7 @@ func TestUnderlayItemConflictUsesScheduledGroupIndex(t *testing.T) {
|
|||||||
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
{source: File(zItem), destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
{source: File(aItem), 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)
|
_, err := testGroupedLoad(request)
|
||||||
loadError, ok := err.(*LoadError)
|
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(zBad), imports: true, destination: Keys("items"), placementMode: placementModeSequenceItem},
|
||||||
{source: File(aGood), 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)
|
_, err := testGroupedLoad(request)
|
||||||
loadError, ok := err.(*LoadError)
|
loadError, ok := err.(*LoadError)
|
||||||
@@ -403,11 +403,11 @@ func TestLayeredCollectionPolicyFlowsBetweenTiers(t *testing.T) {
|
|||||||
filesystem := NewFilesystem("overlay", fstest.MapFS{
|
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")},
|
"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]{
|
request = loadRequest[map[string]any]{
|
||||||
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
||||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\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},
|
overlay: "testing", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||||
}
|
}
|
||||||
result, err = testGroupedLoad(request)
|
result, err = testGroupedLoad(request)
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
package engine
|
package engine
|
||||||
|
|
||||||
type sequenceRegistration struct {
|
|
||||||
path Path
|
|
||||||
identityField string
|
|
||||||
}
|
|
||||||
|
|
||||||
type sequenceSelectorSegmentKind uint8
|
type sequenceSelectorSegmentKind uint8
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,6 +17,24 @@ type sequenceIdentitySelector []sequenceSelectorSegment
|
|||||||
type sequenceIdentityRegistration struct {
|
type sequenceIdentityRegistration struct {
|
||||||
selector sequenceIdentitySelector
|
selector sequenceIdentitySelector
|
||||||
identityFields []string
|
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 {
|
type sequenceSelectorTrie struct {
|
||||||
@@ -32,62 +45,59 @@ type sequenceSelectorTrie struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type sequencePolicy struct {
|
type sequencePolicy struct {
|
||||||
// identityFields preserves the old exact-path view for existing internal
|
selectorTrie *sequenceSelectorTrie
|
||||||
// callers while selector matching is introduced behind the same policy.
|
|
||||||
identityFields map[string]string
|
|
||||||
selectorTrie *sequenceSelectorTrie
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// DefinitionWithSequenceRegistrations attaches facade-owned registrations to a
|
// DefinitionWithSequenceIdentityRegistrations attaches facade-owned
|
||||||
// detached engine definition without exposing the registration representation.
|
// registrations to a detached engine definition without exposing compiled
|
||||||
func DefinitionWithSequenceRegistrations[T any](definition Definition[T], paths []Path, identityFields []string) Definition[T] {
|
// engine policy.
|
||||||
if paths == nil {
|
func DefinitionWithSequenceIdentityRegistrations[T any](definition Definition[T], registrations []SequenceIdentityRegistrationInput) Definition[T] {
|
||||||
definition.sequenceRegistrations = nil
|
if registrations == nil {
|
||||||
|
definition.sequenceIdentityRegistrations = nil
|
||||||
return definition
|
return definition
|
||||||
}
|
}
|
||||||
definition.sequenceRegistrations = make([]sequenceRegistration, len(paths))
|
definition.sequenceIdentityRegistrations = make([]sequenceIdentityRegistration, len(registrations))
|
||||||
for index := range paths {
|
for index, registration := range registrations {
|
||||||
definition.sequenceRegistrations[index] = sequenceRegistration{
|
compiled := sequenceIdentityRegistration{identityFields: append([]string(nil), registration.IdentityFields...)}
|
||||||
path: paths[index].clone(), identityField: identityFields[index],
|
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
|
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) {
|
func compileSequenceIdentityPolicy(registrations []sequenceIdentityRegistration) (sequencePolicy, []Diagnostic) {
|
||||||
policy := sequencePolicy{}
|
policy := sequencePolicy{}
|
||||||
compiled := make([]sequenceIdentityRegistration, 0, len(registrations))
|
compiled := make([]sequenceIdentityRegistration, 0, len(registrations))
|
||||||
var diagnostics []Diagnostic
|
var diagnostics []Diagnostic
|
||||||
for _, registration := range registrations {
|
for _, registration := range registrations {
|
||||||
selector := cloneSequenceIdentitySelector(registration.selector)
|
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)
|
fields, err := normalizeSequenceIdentityFields(registration.identityFields)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
diagnostics = append(diagnostics, Diagnostic{
|
diagnostics = append(diagnostics, Diagnostic{
|
||||||
@@ -172,11 +182,7 @@ func (p sequencePolicy) identityFieldsAt(path Path) ([]string, bool) {
|
|||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
if p.selectorTrie == nil {
|
if p.selectorTrie == nil {
|
||||||
field, ok := p.identityFields[path.String()]
|
return nil, false
|
||||||
if !ok {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
return []string{field}, true
|
|
||||||
}
|
}
|
||||||
current := []*sequenceSelectorTrie{p.selectorTrie}
|
current := []*sequenceSelectorTrie{p.selectorTrie}
|
||||||
for _, segment := range path.segments {
|
for _, segment := range path.segments {
|
||||||
|
|||||||
@@ -7,54 +7,44 @@ import (
|
|||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
|
func TestCompileSequenceIdentityPolicyNormalizesRegistrations(t *testing.T) {
|
||||||
parsed, err := ParsePath("/items/0/a~1b")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
registrations []sequenceRegistration
|
registrations []sequenceIdentityRegistration
|
||||||
want map[string]string
|
want map[string]string
|
||||||
wantError bool
|
wantError bool
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "identical registrations coalesce",
|
name: "identical registrations coalesce",
|
||||||
registrations: []sequenceRegistration{
|
registrations: []sequenceIdentityRegistration{
|
||||||
{path: Keys("items"), identityField: "id"},
|
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
|
||||||
{path: Keys("items"), identityField: "id"},
|
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
|
||||||
},
|
},
|
||||||
want: map[string]string{"/items": "id"},
|
want: map[string]string{"/items": "id"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "different identity fields conflict",
|
name: "different identity fields conflict",
|
||||||
registrations: []sequenceRegistration{
|
registrations: []sequenceIdentityRegistration{
|
||||||
{path: Keys("items"), identityField: "id"},
|
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"id"}},
|
||||||
{path: Keys("items"), identityField: "name"},
|
{selector: sequenceIdentitySelector{literalSequenceSelector("items")}, identityFields: []string{"name"}},
|
||||||
},
|
},
|
||||||
wantError: true,
|
wantError: true,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "empty identity field is literal",
|
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": ""},
|
want: map[string]string{"/items": ""},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "numeric and escaped tokens remain literal keys",
|
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"},
|
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",
|
name: "unused and root registrations are accepted",
|
||||||
registrations: []sequenceRegistration{
|
registrations: []sequenceIdentityRegistration{
|
||||||
{path: Path{}, identityField: "id"},
|
{selector: nil, identityFields: []string{"id"}},
|
||||||
{path: Keys("unused"), identityField: "name"},
|
{selector: sequenceIdentitySelector{literalSequenceSelector("unused")}, identityFields: []string{"name"}},
|
||||||
},
|
},
|
||||||
want: map[string]string{"": "id", "/unused": "name"},
|
want: map[string]string{"": "id", "/unused": "name"},
|
||||||
},
|
},
|
||||||
@@ -63,7 +53,7 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
|
|||||||
|
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
policy, diagnostics := compileSequencePolicy(test.registrations)
|
policy, diagnostics := compileSequenceIdentityPolicy(test.registrations)
|
||||||
if (len(diagnostics) != 0) != test.wantError {
|
if (len(diagnostics) != 0) != test.wantError {
|
||||||
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
|
t.Fatalf("diagnostics = %#v, wantError = %t", diagnostics, test.wantError)
|
||||||
}
|
}
|
||||||
@@ -73,12 +63,14 @@ func TestCompileSequencePolicyNormalizesRegistrations(t *testing.T) {
|
|||||||
}
|
}
|
||||||
return
|
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 {
|
for path, field := range test.want {
|
||||||
if got := policy.identityFields[path]; got != field {
|
parsed, err := ParsePath(path)
|
||||||
t.Errorf("identity field at %q = %q, want %q", path, got, field)
|
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")
|
ordinaryReader := strings.NewReader("value: ordinary\n")
|
||||||
_, err := Load[config](Reader("ordinary.yml", ordinaryReader), SecretBinding{}, Options[config]{
|
_, 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)
|
requireSequenceRegistrationOptionError(t, err)
|
||||||
if ordinaryReader.Len() == 0 {
|
if ordinaryReader.Len() == 0 {
|
||||||
@@ -257,7 +249,7 @@ func TestSequenceRegistrationPreflightDoesNotReadSources(t *testing.T) {
|
|||||||
mainReader := strings.NewReader("value: main\n")
|
mainReader := strings.NewReader("value: main\n")
|
||||||
underlayReader := strings.NewReader("value: underlay\n")
|
underlayReader := strings.NewReader("value: underlay\n")
|
||||||
_, err = LoadLayered[config](Input{Source: Reader("main.yml", mainReader)}, SecretBinding{}, LayeredOptions[config]{
|
_, 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)}}}},
|
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayReader)}}}},
|
||||||
})
|
})
|
||||||
requireSequenceRegistrationOptionError(t, err)
|
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) {
|
func requireSequenceRegistrationOptionError(t *testing.T, err error) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
if err == nil {
|
if err == nil {
|
||||||
@@ -289,7 +283,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
|||||||
type config struct {
|
type config struct {
|
||||||
Items []item `yaml:"items"`
|
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}
|
registered := Options[config]{Definition: definition}
|
||||||
loadCode := func(t *testing.T, err error) Code {
|
loadCode := func(t *testing.T, err error) Code {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
@@ -327,7 +321,7 @@ func TestRegisteredSequencePolicyAcrossLoadingLifecycle(t *testing.T) {
|
|||||||
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
|
{source: Bytes("first", []byte("items: [{id: lower}]\n"))},
|
||||||
{source: Bytes("second", []byte("items: [{id: other}]\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)
|
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" {
|
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]{
|
request := loadRequest[config]{
|
||||||
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
|
main: loadInput{source: Bytes("main", []byte("items: []\n"))},
|
||||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: lower}]\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)
|
result, err = testGroupedLoad(request)
|
||||||
if err != nil || len(result.Config.Items) != 1 || result.Config.Items[0].ID != "lower" {
|
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]{
|
request := loadRequest[config]{
|
||||||
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
main: loadInput{source: Bytes("main", []byte("items: [{id: main}]\n"))},
|
||||||
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("items: [{id: same}, {id: same}]\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 {
|
if got := loadCode(t, func() error { _, err := testGroupedLoad(request); return err }()); got != CodeComposeConflict {
|
||||||
t.Fatalf("hidden lower-tier error = %s", got)
|
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()
|
t.Helper()
|
||||||
policy, diagnostics := compileSequencePolicy(registrations)
|
policy, diagnostics := compileSequenceIdentityPolicy(registrations)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
t.Fatal(diagnostics)
|
t.Fatal(diagnostics)
|
||||||
}
|
}
|
||||||
|
|||||||
40
lifecycle.go
40
lifecycle.go
@@ -15,15 +15,11 @@ type Definition[T any] struct {
|
|||||||
Validate func(*T, *Validation)
|
Validate func(*T, *Validation)
|
||||||
Version *VersionRule
|
Version *VersionRule
|
||||||
Secret func(Path) bool
|
Secret func(Path) bool
|
||||||
// SequenceCompositions registers mapping paths whose sequence contributions
|
// SequenceIdentities declares the scalar fields used to combine records and
|
||||||
// strictly append distinct records by the selected literal identity field.
|
// resolve record inheritance at selected sequence destinations. Registration
|
||||||
// Invalid or conflicting registrations fail option validation. Repeated input
|
// errors are reported before source acquisition. These constraints do not yet
|
||||||
// identities fail strict composition after source and helper processing. The
|
// validate identities introduced by defaults, CLI overrides, or callbacks.
|
||||||
// field also supplies identity for parent lookup when surviving records at
|
SequenceIdentities []SequenceIdentity
|
||||||
// that path use record-inheritance directives.
|
|
||||||
// Registration paths are copied when a load begins; definitions are read-only
|
|
||||||
// after being shared.
|
|
||||||
SequenceCompositions []SequenceComposition
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// PlacementMode selects how an input mapping is placed at its Destination.
|
// PlacementMode selects how an input mapping is placed at its Destination.
|
||||||
@@ -38,20 +34,6 @@ const (
|
|||||||
PlacementModeSequenceItem
|
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
|
// SourceVersionMode controls schema-version declarations across one complete
|
||||||
// load. The zero value preserves the existing main-only effective-path check
|
// load. The zero value preserves the existing main-only effective-path check
|
||||||
// and its protected-path behavior. Enabled modes check participating documents
|
// 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] {
|
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
|
||||||
result := engine.Definition[T]{Defaults: definition.Defaults}
|
result := engine.Definition[T]{Defaults: definition.Defaults}
|
||||||
if definition.SequenceCompositions != nil {
|
if definition.SequenceIdentities != nil {
|
||||||
paths := make([]engine.Path, len(definition.SequenceCompositions))
|
registrations := make([]engine.SequenceIdentityRegistrationInput, len(definition.SequenceIdentities))
|
||||||
identityFields := make([]string, len(definition.SequenceCompositions))
|
for index, identity := range definition.SequenceIdentities {
|
||||||
for index, registration := range definition.SequenceCompositions {
|
registrations[index] = toEngineSequenceIdentity(identity)
|
||||||
paths[index] = toEnginePath(registration.Path.clone())
|
|
||||||
identityFields[index] = registration.IdentityField
|
|
||||||
}
|
}
|
||||||
result = engine.DefinitionWithSequenceRegistrations(result, paths, identityFields)
|
result = engine.DefinitionWithSequenceIdentityRegistrations(result, registrations)
|
||||||
}
|
}
|
||||||
if definition.Resolve != nil {
|
if definition.Resolve != nil {
|
||||||
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
|
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