Resolve inheritance between identified records
This commit is contained in:
@@ -222,41 +222,55 @@ is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
|||||||
|
|
||||||
Any input mapping may declare `extends: name` to inherit from the mapping named
|
Any input mapping may declare `extends: name` to inherit from the mapping named
|
||||||
`name` among its immediate siblings. Names are exact keys, including the empty
|
`name` among its immediate siblings. Names are exact keys, including the empty
|
||||||
string; lookup does not search ancestor or unrelated scopes. The document root
|
string; lookup does not search ancestor or unrelated scopes. A mapping inside a
|
||||||
and a sequence-element mapping have no sibling namespace for their own string
|
sequence can also participate in record inheritance when that sequence has an
|
||||||
relationship, though mappings nested inside them can have one. An `extends`
|
effective identity declaration from `configkit.sequences` or an application
|
||||||
|
registration. In that case, `extends` is a mapping containing exactly the
|
||||||
|
sequence's identity fields, with scalar values; lookup is limited to that final
|
||||||
|
sequence. The document root and a sequence-element mapping without an effective
|
||||||
|
identity declaration have no sibling namespace for non-null string
|
||||||
|
relationships, though mappings nested inside them can have one. An `extends`
|
||||||
value of null cancels a relationship without looking up a parent. Other value
|
value of null cancels a relationship without looking up a parent. Other value
|
||||||
types, missing or non-mapping parents, and cycles fail the load with value-free
|
types, incomplete or extra reference fields, missing parents, and cycles fail
|
||||||
diagnostics.
|
the load with value-free diagnostics.
|
||||||
|
|
||||||
Within each sibling scope, direct mapping definitions resolve by dependency
|
Within each mapping sibling scope or identified sequence, direct definitions
|
||||||
readiness. A parent is resolved before a child; among currently eligible
|
resolve by dependency readiness. A parent is resolved before a child; among
|
||||||
definitions, the lexically smallest canonical semantic pointer is selected,
|
currently eligible definitions, the lexically smallest canonical semantic
|
||||||
including newly eligible definitions as soon as their parent completes. This
|
pointer is selected, including newly eligible definitions as soon as their
|
||||||
ordering governs inheritance operations and their history, controls, and
|
parent completes. This ordering governs inheritance operations and their
|
||||||
transfers. It does not reorder the mappings in the resulting configuration.
|
history, controls, and transfers. It does not reorder mappings or sequence
|
||||||
|
records in the resulting configuration.
|
||||||
|
|
||||||
`extends` is reserved structural input across ordinary and layered loads, every
|
`extends` is reserved structural input across ordinary and layered loads, every
|
||||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||||
consumed before typed decoding and need not appear in the application's Go
|
consumed before typed decoding and need not appear in the application's Go
|
||||||
types. Applications previously using `extends` as an ordinary YAML field must
|
types. Applications previously using `extends` as an ordinary YAML field must
|
||||||
rename that key and its field. No collection registration or keyword setup is
|
rename that key and its field. No collection registration or keyword setup is
|
||||||
required.
|
required for mapping-sibling relationships; identified-sequence relationships
|
||||||
|
require effective identity fields for that collection.
|
||||||
|
|
||||||
ConfigKit completes strict composition within tiers and precedence assembly
|
ConfigKit completes strict composition within tiers and precedence assembly
|
||||||
between tiers before resolving relationships. An omitted `extends` retains a
|
between tiers before resolving relationships. Only records that survive that
|
||||||
lower-priority relationship; `extends: null` cancels it while preserving the
|
assembly participate in identified-sequence lookup: an upserted parent supplies
|
||||||
child's own settings. Superseded references are historical evidence, not active
|
its final record, a replaced parent is unavailable, and a higher replacement
|
||||||
relationships to validate. Each child recursively overlays its own settings
|
without `extends` supersedes a lower relationship. An omitted `extends` retains
|
||||||
over its resolved parent: explicit zero, false, empty string, and
|
a lower-priority mapping-sibling relationship; an identified record's effective
|
||||||
null values replace inherited values, while mappings combine recursively.
|
directive depends on the record that survives assembly. `extends: null` cancels
|
||||||
|
a relationship while preserving the child's own settings. Superseded references
|
||||||
|
are historical evidence, not active relationships to validate. Each child
|
||||||
|
recursively overlays its own settings over its resolved parent: explicit zero,
|
||||||
|
false, empty string, and
|
||||||
|
null values replace inherited values, while mappings combine recursively. For
|
||||||
|
identified records, the child's identity fields and their origins remain its
|
||||||
|
own; parent identity fields do not contribute to the payload.
|
||||||
Sequences at collection destinations use the shared collection operation with
|
Sequences at collection destinations use the shared collection operation with
|
||||||
the parent as lower input and the child as higher input. The destination's
|
the parent as lower input and the child as higher input. The destination's
|
||||||
explicit mode applies; otherwise a copied identity selects upsert and an
|
explicit mode applies; otherwise a copied identity selects upsert and an
|
||||||
unkeyed destination replaces. A parent's explicit mode does not move to a
|
unkeyed destination replaces. A parent's explicit mode does not move to a
|
||||||
different destination. Sequences outside collection destinations remain atomic
|
different destination. Sequences outside collection destinations remain atomic
|
||||||
replacements.
|
replacements.
|
||||||
The child retains its key. All effective relationships resolve, including
|
All effective relationships resolve, including
|
||||||
entries the application may never select for execution. Partial parent
|
entries the application may never select for execution. Partial parent
|
||||||
definitions undergo normal typed decoding and application-owned validation,
|
definitions undergo normal typed decoding and application-owned validation,
|
||||||
not a special structural exemption.
|
not a special structural exemption.
|
||||||
@@ -822,12 +836,14 @@ Identical registrations coalesce; conflicting registrations at one path and
|
|||||||
invalid registration paths produce `options.invalid`. Unused and root registrations
|
invalid registration paths produce `options.invalid`. Unused and root registrations
|
||||||
are allowed and have no effect.
|
are allowed and have no effect.
|
||||||
|
|
||||||
Registration changes strict composition only. Each underlay group and the main
|
Registration controls strict composition and supplies identity fields for parent
|
||||||
tier has independent duplicate-identity state. Precedence assembly is unchanged:
|
lookup when surviving records in that sequence use `extends`. Each underlay group
|
||||||
a present higher-priority sequence replaces the lower sequence as one value,
|
and the main tier has independent duplicate-identity state. Precedence assembly
|
||||||
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
|
is unchanged: a present higher-priority sequence replaces the lower sequence as
|
||||||
and application resolution do not run the registered identity check. The accepted
|
one value, including at a registered path. Registration does not add a final
|
||||||
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
identity check to defaults, application resolution, or the published typed
|
||||||
|
configuration. The accepted rationale is recorded in
|
||||||
|
[ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
||||||
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
||||||
shows item path and glob imports, helper-supplied identity, and main-tier
|
shows item path and glob imports, helper-supplied identity, and main-tier
|
||||||
replacement of a lower-priority sequence.
|
replacement of a lower-priority sequence.
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||||
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||||
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
|
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, same-sequence record inheritance, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
|
||||||
|
|
||||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
@@ -206,10 +206,17 @@ are complete local examples.
|
|||||||
keep the smaller adoption path visible: one explicitly required file import,
|
keep the smaller adoption path visible: one explicitly required file import,
|
||||||
the same identity registration, and the same application validation.
|
the same identity registration, and the same application validation.
|
||||||
|
|
||||||
|
The registered `id` also identifies surviving prompt records that use
|
||||||
|
`extends: {id: "base-id"}`. ConfigKit resolves these references within the
|
||||||
|
completed prompt sequence after tier assembly, preserves the child's own ID, and
|
||||||
|
records inherited values at the child's paths. The
|
||||||
|
[configuration contract](../config.md#sibling-inheritance) defines the exact
|
||||||
|
reference and nested collection behavior.
|
||||||
|
|
||||||
The example's `validatePromptCatalog` remains application-owned. It checks that
|
The example's `validatePromptCatalog` remains application-owned. It checks that
|
||||||
prompt names and message lists satisfy this consumer's requirements after
|
prompt names and message lists satisfy this consumer's requirements after
|
||||||
ConfigKit has composed, projected, and decoded the configuration. It does not
|
ConfigKit has composed, inherited, projected, and decoded the configuration.
|
||||||
add recursive record merging, identity-based overlay, or final uniqueness checks
|
ConfigKit does not apply identity-based overlays or enforce final uniqueness
|
||||||
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
||||||
[import syntax](../config.md#explicit-imports), and
|
[import syntax](../config.md#explicit-imports), and
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Sequence Identity And Combination Implementation Plan
|
# Sequence Identity And Combination Implementation Plan
|
||||||
|
|
||||||
Status: implementation in progress; Stages 1–20 complete.
|
Status: implementation in progress; Stages 1–21 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
|
||||||
@@ -732,6 +732,11 @@ expansion phase, with values and inspection working together.
|
|||||||
Validation: all inheritance and cross-feature inspection tests,
|
Validation: all inheritance and cross-feature inspection tests,
|
||||||
`go test -race ./internal/engine`, and the everyday check.
|
`go test -race ./internal/engine`, and the everyday check.
|
||||||
|
|
||||||
|
**Completion:** Activated identified-record inheritance after tier assembly,
|
||||||
|
including nested collection relationships, ownership and inspection evidence.
|
||||||
|
Verified inheritance/inspection regressions, `go test -race ./internal/engine`,
|
||||||
|
and `./scripts/check.sh`.
|
||||||
|
|
||||||
## Stage 22 — Expose The Root-Owned Registration API
|
## Stage 22 — Expose The Root-Owned Registration API
|
||||||
|
|
||||||
Scope: the settled facade surface and migration of in-repository callers.
|
Scope: the settled facade surface and migration of in-repository callers.
|
||||||
|
|||||||
@@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) {
|
||||||
|
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET"
|
||||||
|
const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type step struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type job struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Steps []step `yaml:"steps"`
|
||||||
|
Settings map[string]map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Jobs []job `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
input := []byte(`jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: parent}
|
||||||
|
name: child-name
|
||||||
|
- id: parent
|
||||||
|
extends: {id: base}
|
||||||
|
- id: base
|
||||||
|
name: base-name
|
||||||
|
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET
|
||||||
|
resource: icon.txt
|
||||||
|
steps:
|
||||||
|
- id: base-step
|
||||||
|
value: base-step-value
|
||||||
|
- id: second-step
|
||||||
|
value: second-step-value
|
||||||
|
settings:
|
||||||
|
primary: {value: base-setting}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
`)
|
||||||
|
filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{
|
||||||
|
"config/identified.yml": &fstest.MapFile{Data: input},
|
||||||
|
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
||||||
|
})
|
||||||
|
registrations := []configkit.SequenceComposition{
|
||||||
|
{Path: configkit.Keys("jobs"), IdentityField: "id"},
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
SequenceCompositions: registrations,
|
||||||
|
Secret: func(path configkit.Path) bool {
|
||||||
|
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||||
|
t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" {
|
||||||
|
t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs)
|
||||||
|
}
|
||||||
|
child := result.Config.Jobs[0]
|
||||||
|
if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 {
|
||||||
|
t.Fatalf("inherited child = %#v", child)
|
||||||
|
}
|
||||||
|
if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" {
|
||||||
|
t.Fatalf("inherited nested steps = %v", got)
|
||||||
|
}
|
||||||
|
if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" {
|
||||||
|
t.Fatalf("inherited nested collection and mapping = %#v", child)
|
||||||
|
}
|
||||||
|
credentialPath := configkit.Keys("jobs").Index(0).Key("credential")
|
||||||
|
credentialInfo, found := result.Metadata().Lookup(credentialPath)
|
||||||
|
if !found || len(credentialInfo.Origin.Inheritance) != 2 {
|
||||||
|
t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found)
|
||||||
|
}
|
||||||
|
resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||||
|
if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found)
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||||
|
if !found || context.BaseDir != "config" || context.FS == nil {
|
||||||
|
t.Fatalf("inherited resource context = %#v, %v", context, found)
|
||||||
|
}
|
||||||
|
resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||||
|
if readErr != nil || string(resource) != "identified-parent-resource" {
|
||||||
|
t.Fatalf("inherited resource = %q, %v", resource, readErr)
|
||||||
|
}
|
||||||
|
resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||||
|
if !found || len(resourceHistory.History) == 0 {
|
||||||
|
t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found)
|
||||||
|
}
|
||||||
|
credentialHistory, found := result.Explain(credentialPath)
|
||||||
|
if !found || !credentialHistory.Effective.Redacted {
|
||||||
|
t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found)
|
||||||
|
}
|
||||||
|
identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id"))
|
||||||
|
if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 ||
|
||||||
|
len(identityInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found)
|
||||||
|
}
|
||||||
|
if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found {
|
||||||
|
t.Fatal("identified record relationship remained in effective metadata")
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") {
|
||||||
|
t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) {
|
||||||
|
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET"
|
||||||
|
const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type job struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Jobs []job `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
input := []byte(`jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: missing}
|
||||||
|
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET
|
||||||
|
`)
|
||||||
|
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("jobs"), IdentityField: "id"}},
|
||||||
|
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
||||||
|
policy := func(mode string) string {
|
||||||
|
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lower string
|
||||||
|
higher string
|
||||||
|
wantCode configkit.Code
|
||||||
|
wantValue string
|
||||||
|
wantSuccess bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "upserted parent supplies its final payload",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n",
|
||||||
|
wantValue: "higher", wantSuccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replacement removes a lower parent before lookup",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n",
|
||||||
|
wantCode: configkit.CodeInheritanceMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upserted child without a directive replaces the invalid lower child",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n",
|
||||||
|
wantValue: "higher", wantSuccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-reference cannot resolve through a lower predecessor",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n",
|
||||||
|
wantCode: configkit.CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}},
|
||||||
|
})
|
||||||
|
if test.wantSuccess {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jobs, ok := result.Config["jobs"].([]any)
|
||||||
|
if !ok || len(jobs) == 0 {
|
||||||
|
t.Fatalf("assembled jobs = %#v", result.Config["jobs"])
|
||||||
|
}
|
||||||
|
child, ok := jobs[len(jobs)-1].(map[string]any)
|
||||||
|
if !ok || child["value"] != test.wantValue {
|
||||||
|
t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("load error = %v, want code %s", err, test.wantCode)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadError.Diagnostics() {
|
||||||
|
if diagnostic.Code == test.wantCode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ type identifiedInheritanceGraph struct {
|
|||||||
byIdentity map[string]int
|
byIdentity map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func sequenceHasIdentifiedInheritanceReferences(sequence *document.Node) bool {
|
||||||
|
if sequence == nil || sequence.Kind != document.Sequence {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for _, item := range sequence.Sequence {
|
||||||
|
if directive, found := inheritanceDirective(item); found && directive.Value.Kind != document.Null {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// analyzeIdentifiedInheritanceGraph validates relationships inside one
|
// analyzeIdentifiedInheritanceGraph validates relationships inside one
|
||||||
// identified sequence. It prepares a graph only; mapping-sibling expansion
|
// identified sequence. It prepares a graph only; mapping-sibling expansion
|
||||||
// remains the active inheritance behavior until the reusable scope executor is
|
// remains the active inheritance behavior until the reusable scope executor is
|
||||||
|
|||||||
@@ -147,6 +147,93 @@ func TestIdentifiedInheritanceScopeAdapterReusesTemplateExecution(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedInheritanceResolvesNestedCollectionsAtEachDestination(t *testing.T) {
|
||||||
|
root := parseItemPolicyDocument(t, "identified-nested", `jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: parent}
|
||||||
|
- id: parent
|
||||||
|
extends: {id: base}
|
||||||
|
steps:
|
||||||
|
- id: child-step
|
||||||
|
extends: {id: parent-step}
|
||||||
|
value: child-value
|
||||||
|
- id: parent-step
|
||||||
|
value: parent-value
|
||||||
|
- id: base
|
||||||
|
steps:
|
||||||
|
- id: base-step
|
||||||
|
value: base-value
|
||||||
|
`)
|
||||||
|
identity := collectionPolicy{identityFields: []string{"id"}, identityPresent: true}
|
||||||
|
policies := collectionPolicyScope{}
|
||||||
|
policies.set(Keys("jobs"), identity)
|
||||||
|
for index := 0; index < 3; index++ {
|
||||||
|
policies.set(Keys("jobs").Index(index).Key("steps"), identity)
|
||||||
|
}
|
||||||
|
expanded, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("expand nested identified inheritance: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
steps, found := documentAtPath(expanded.root, Keys("jobs").Index(0).Key("steps"))
|
||||||
|
if !found || steps.Kind != document.Sequence || len(steps.Sequence) != 3 {
|
||||||
|
t.Fatalf("child nested steps = %#v, found %t", steps, found)
|
||||||
|
}
|
||||||
|
for index, want := range []string{"base-step", "child-step", "parent-step"} {
|
||||||
|
id, exists := documentAtPath(steps.Sequence[index], Keys("id"))
|
||||||
|
if !exists || id.Text != want {
|
||||||
|
t.Fatalf("child step %d identity = %#v, found %t; want %q", index, id, exists, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
value, found := documentAtPath(steps.Sequence[1], Keys("value"))
|
||||||
|
if !found || value.Text != "child-value" {
|
||||||
|
t.Fatalf("nested record inheritance = %#v, found %t", value, found)
|
||||||
|
}
|
||||||
|
if containsInheritanceDirective(expanded.root) {
|
||||||
|
t.Fatal("a nested record relationship remained after destination-local expansion")
|
||||||
|
}
|
||||||
|
childIdentity, found := expanded.origins["/jobs/0/id"]
|
||||||
|
if !found || len(childIdentity.Locations) == 0 || childIdentity.Locations[0].SourceID != "identified-nested" {
|
||||||
|
t.Fatalf("child identity origin = %#v, found %t", childIdentity, found)
|
||||||
|
}
|
||||||
|
if _, found := expanded.origins["/jobs/0/steps/1/value"]; !found {
|
||||||
|
t.Fatal("nested inherited value did not retain its destination origin")
|
||||||
|
}
|
||||||
|
for _, replacement := range expanded.replacements {
|
||||||
|
if replacement.path.String() == "/jobs/0/id" {
|
||||||
|
t.Fatal("parent identity was treated as payload replaced by the child's identity")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestIdentifiedInheritanceRespectsOutputNodeBudget(t *testing.T) {
|
||||||
|
root := parseItemPolicyDocument(t, "identified-budget", `jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: parent}
|
||||||
|
- id: parent
|
||||||
|
value: inherited
|
||||||
|
`)
|
||||||
|
policies := collectionPolicyScope{}
|
||||||
|
policies.set(Keys("jobs"), collectionPolicy{identityFields: []string{"id"}, identityPresent: true})
|
||||||
|
baseline, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, defaultProjectionLimits())
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("unbounded identified inheritance: %#v", diagnostics)
|
||||||
|
}
|
||||||
|
outputNodes, within := inheritanceLowerBoundCount(baseline.root, -1)
|
||||||
|
if !within || outputNodes < 2 {
|
||||||
|
t.Fatalf("identified output node count = %d, within %t", outputNodes, within)
|
||||||
|
}
|
||||||
|
limits := defaultProjectionLimits()
|
||||||
|
limits.nodes = outputNodes
|
||||||
|
if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result == nil || len(diagnostics) != 0 {
|
||||||
|
t.Fatalf("at output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
limits.nodes = outputNodes - 1
|
||||||
|
if result, diagnostics := expandInheritanceWithCollectionRules(root, nil, policies, sequencePolicy{}, limits); result != nil ||
|
||||||
|
len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
|
||||||
|
t.Fatalf("below output-node boundary: result = %#v, diagnostics = %#v", result, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) {
|
func TestIdentifiedInheritanceRequiresChildIdentityBeforeParentLookup(t *testing.T) {
|
||||||
sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child
|
sequence := parseIdentifiedInheritanceSequence(t, "missing-child-field", `- id: child
|
||||||
extends: {id: parent, tenant: acme}
|
extends: {id: parent, tenant: acme}
|
||||||
|
|||||||
@@ -220,7 +220,7 @@ func applyInheritanceScopeTemplates(adapter inheritanceScopeAdapter, order []int
|
|||||||
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
|
effectivePolicies := append(cloneInheritancePolicyTemplates(parentPolicies), cloneCollectionPolicyScopeForInheritance(templatePolicies))
|
||||||
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
|
parentPolicyScope := projectInheritanceCollectionPolicies(resolvedPolicies[node.parent], node.path)
|
||||||
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
|
childPolicyScope := projectCurrentInheritanceCollectionPolicies(templatePolicies, node.path)
|
||||||
merged := document.Clone(parent)
|
merged := inheritanceParentPayload(parent, node.identityFields)
|
||||||
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
|
mergedPolicyScope, policyDiagnostics := mergeInheritanceCollectionPolicies(
|
||||||
parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(),
|
parentPolicyScope, childPolicyScope, node.path, parent, adapter.applicationPolicy(),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -159,6 +159,25 @@ func preserveInheritanceIdentityFields(merged, child *document.Node, fields []st
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func inheritanceParentPayload(parent *document.Node, identityFields []string) *document.Node {
|
||||||
|
cloned := document.Clone(parent)
|
||||||
|
if len(identityFields) == 0 || cloned == nil || cloned.Kind != document.Mapping {
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
excluded := make(map[string]struct{}, len(identityFields))
|
||||||
|
for _, field := range identityFields {
|
||||||
|
excluded[field] = struct{}{}
|
||||||
|
}
|
||||||
|
kept := cloned.Mapping[:0]
|
||||||
|
for _, entry := range cloned.Mapping {
|
||||||
|
if _, omit := excluded[entry.Key]; !omit {
|
||||||
|
kept = append(kept, entry)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cloned.Mapping = kept
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|
||||||
func applyIdentifiedInheritanceTemplates(
|
func applyIdentifiedInheritanceTemplates(
|
||||||
graph *identifiedInheritanceGraph,
|
graph *identifiedInheritanceGraph,
|
||||||
limits projectionLimits,
|
limits projectionLimits,
|
||||||
|
|||||||
@@ -158,6 +158,11 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
case document.Sequence:
|
case document.Sequence:
|
||||||
|
if sequenceHasIdentifiedInheritanceReferences(*node) {
|
||||||
|
if problem := w.expandIdentifiedSequence(node, path); problem != nil {
|
||||||
|
return problem
|
||||||
|
}
|
||||||
|
}
|
||||||
working := **node
|
working := **node
|
||||||
working.Sequence = slices.Clone(working.Sequence)
|
working.Sequence = slices.Clone(working.Sequence)
|
||||||
*node = &working
|
*node = &working
|
||||||
@@ -171,6 +176,60 @@ func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (w *inheritanceWalker) expandIdentifiedSequence(node **document.Node, path Path) *Diagnostic {
|
||||||
|
policy, hasPolicy := w.collectionPolicies.at(path)
|
||||||
|
applicationFields, _ := w.applicationPolicy.identityFieldsAt(path)
|
||||||
|
var lower *collectionPolicy
|
||||||
|
if hasPolicy {
|
||||||
|
lower = &policy
|
||||||
|
}
|
||||||
|
resolution, policyDiagnostics := resolveCollectionPolicy(path, nil, applicationFields, lower)
|
||||||
|
if len(policyDiagnostics) != 0 {
|
||||||
|
return &policyDiagnostics[0]
|
||||||
|
}
|
||||||
|
// Without an identity rule, retain the existing diagnostic for a reference
|
||||||
|
// that has no enclosing sibling scope. A null cancellation needs no lookup.
|
||||||
|
if len(resolution.effectiveIdentityFields) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
original := *node
|
||||||
|
graph, diagnostics := analyzeIdentifiedInheritanceGraph(*node, path, resolution.effectiveIdentityFields)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return &diagnostics[0]
|
||||||
|
}
|
||||||
|
before, _ := inheritanceLowerBoundCount(*node, -1)
|
||||||
|
limits := w.limits
|
||||||
|
if limits.nodes >= 0 {
|
||||||
|
limits.nodes -= w.nodes - before
|
||||||
|
}
|
||||||
|
resolved, diagnostics := applyIdentifiedInheritanceTemplates(
|
||||||
|
graph, limits, w.origins, w.collectionPolicies, w.applicationPolicy, w.retained,
|
||||||
|
)
|
||||||
|
if len(diagnostics) != 0 {
|
||||||
|
return &diagnostics[0]
|
||||||
|
}
|
||||||
|
*node = resolved.root
|
||||||
|
for pointer, entry := range w.collectionPolicies.collections {
|
||||||
|
if pathHasPrefix(entry.destination, path) {
|
||||||
|
delete(w.collectionPolicies.collections, pointer)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, entry := range resolved.collectionPolicies.collections {
|
||||||
|
w.collectionPolicies.set(entry.destination, entry.policy)
|
||||||
|
}
|
||||||
|
deleteOriginSubtree(w.origins, path, original)
|
||||||
|
for pointer, origin := range resolved.origins {
|
||||||
|
w.origins[pointer] = origin
|
||||||
|
}
|
||||||
|
w.transfers = append(w.transfers, resolved.transfers...)
|
||||||
|
w.controls = append(w.controls, resolved.controls...)
|
||||||
|
w.replacements = append(w.replacements, resolved.replacements...)
|
||||||
|
w.operations = append(w.operations, resolved.operations...)
|
||||||
|
after, _ := inheritanceLowerBoundCount(*node, -1)
|
||||||
|
w.nodes += after - before
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func scopeHasInheritanceDefinitions(scope *document.Node) bool {
|
func scopeHasInheritanceDefinitions(scope *document.Node) bool {
|
||||||
for _, entry := range scope.Mapping {
|
for _, entry := range scope.Mapping {
|
||||||
if entry.Value.Kind != document.Mapping {
|
if entry.Value.Kind != document.Mapping {
|
||||||
|
|||||||
@@ -18,7 +18,9 @@ type Definition[T any] struct {
|
|||||||
// SequenceCompositions registers mapping paths whose sequence contributions
|
// SequenceCompositions registers mapping paths whose sequence contributions
|
||||||
// strictly append distinct records by the selected literal identity field.
|
// strictly append distinct records by the selected literal identity field.
|
||||||
// Invalid or conflicting registrations fail option validation. Repeated input
|
// Invalid or conflicting registrations fail option validation. Repeated input
|
||||||
// identities fail strict composition after source and helper processing.
|
// identities fail strict composition after source and helper processing. The
|
||||||
|
// field also supplies identity for parent lookup when surviving records at
|
||||||
|
// that path use record-inheritance directives.
|
||||||
// Registration paths are copied when a load begins; definitions are read-only
|
// Registration paths are copied when a load begins; definitions are read-only
|
||||||
// after being shared.
|
// after being shared.
|
||||||
SequenceCompositions []SequenceComposition
|
SequenceCompositions []SequenceComposition
|
||||||
@@ -46,6 +48,7 @@ type SequenceComposition struct {
|
|||||||
Path Path
|
Path Path
|
||||||
// IdentityField is one literal mapping key and may be empty. Records must
|
// IdentityField is one literal mapping key and may be empty. Records must
|
||||||
// contain a string at this key; malformed records fail strict composition.
|
// contain a string at this key; malformed records fail strict composition.
|
||||||
|
// The field also identifies parents for record inheritance at this path.
|
||||||
IdentityField string
|
IdentityField string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user