Expose sequence identity registrations through the facade

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

View File

@@ -71,8 +71,8 @@ missing file remains an error; it is never interpreted as no input.
`Options` accepts the application definition, ordinary-source `Destination` and `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.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,7 +21,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} } 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"
}, },

View File

@@ -23,9 +23,9 @@ func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T)
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} { 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"

View File

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

View File

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

View File

@@ -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}]"),

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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